> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.portreeve.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.portreeve.com/_mcp/server.

# Keys and your first verdict

## Keys

Keys live in **Settings → API keys** in the dashboard. Your account has live
and test keys, and they come in two kinds:

### Server — secret keys

|  | Key |
|---|---|
| **Live** | `sk_live_…` |
| **Test** | `sk_test_…` |

Used for the Verdict, feedback, and event APIs. These stay on your server
(secrets manager); never expose them in client-side code.

### Browser — publishable keys

|  | Key |
|---|---|
| **Live** | `pk_live_…` |
| **Test** | `pk_test_…` |

Used only to mint device tokens. Safe to ship to the browser. See
[device fingerprinting](/integration/device-fingerprinting).

> **Test mode is fully isolated.** Test events, velocity counters, and clusters
> never affect live data — same engine, separate world.

## Install

```bash
npm install portreeve
```

Node 18.17+. The SDK has zero runtime dependencies; types come from the
published API contract. Not on Node? Skip to
[REST](/reference/rest-api) — the API is six endpoints.

## First verdict

The same screening, in the two most common shapes:

<Tabs>
<Tab title="Next.js route handler">


```ts
// app/api/signup/route.ts
import { Portreeve } from "portreeve";

const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

export async function POST(req: Request) {
  const { email, device_token } = (await req.json()) as {
    email: string;
    device_token?: string;
  };

  // Behind a proxy/CDN (Vercel, Cloudflare, Railway) this header is set.
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
  if (!ip) return Response.json({ error: "no_client_ip" }, { status: 400 });

  // Screen BEFORE creating the account, with the id you're about to use.
  const userId = crypto.randomUUID();
  const result = await portreeve.verdict({
    event_type: "signup",
    external_user_id: userId,
    email,
    ip,
    user_agent: req.headers.get("user-agent") ?? undefined,
    device_token,
  });

  if (result.verdict === "block") {
    return Response.json({ error: "signup_rejected" }, { status: 403 });
  }

  // "review" proceeds. A degraded (fail-open) result has no server event — its id is null.
  await createUser({
    id: userId,
    email,
    portreeveEventId: result.degraded ? null : result.id,
  });

  return Response.json({ ok: true });
}

// --- stand-in for your own app code ---
declare function createUser(user: {
  id: string;
  email: string;
  portreeveEventId: string | null;
}): Promise<void>;
```

</Tab>
<Tab title="Express middleware">


```ts
import express from "express";
import { Portreeve, type VerdictResult } from "portreeve";

const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!, {
  timeoutMs: 400, //    default: total budget for the verdict call
  failMode: "allow", // default: on timeout/outage, allow + degraded: true
});

declare global {
  namespace Express {
    interface Request {
      portreeve?: VerdictResult;
    }
  }
}

export function screenSignup(): express.RequestHandler {
  return async (req, res, next) => {
    const result = await portreeve.verdict({
      event_type: "signup",
      email: req.body.email,
      ip: req.ip!, // set app.set("trust proxy", 1) behind a proxy
      user_agent: req.get("user-agent"),
      device_token: req.body.device_token,
    });

    if (result.verdict === "block") {
      res.status(403).json({ error: "signup_rejected" });
      return;
    }

    req.portreeve = result; // "review" proceeds
    next();
  };
}

const app = express();
app.set("trust proxy", 1);
app.use(express.json());

app.post("/signup", screenSignup(), async (req, res) => {
  const screening = req.portreeve!;
  // Guard on degraded: a fail-open result has no server event id to link.
  await createAccount(req.body.email, screening.degraded ? null : screening.id);
  res.json({ ok: true });
});

// --- stand-in for your own app code ---
declare function createAccount(
  email: string,
  portreeveEventId: string | null
): Promise<void>;
```

</Tab>
</Tabs>

The three decisions both shapes encode:

- **Call before you create the account**, with the id you are about to use as
  `external_user_id`. That id is how Portreeve links events to accounts, and
  how a later `review.resolved` webhook tells you *which* user to revoke.
- **On `block`, return a generic error.** Don't tell abusers what tripped them.
- **On `review` (and `allow`), proceed** — and persist `result.id` with the
  user, guarding on `result.degraded` first: a degraded verdict has no server
  event, so its `id` is `null` (see
  [failure behavior](#client-options-and-failure-behavior)).

## When your stack can't "call first"

The shape above assumes your server mints its own user ids and owns its signup
route. Many stacks don't — here is the honest version for each:

### Your auth provider mints the user id

Supabase, Auth0, Clerk, and Firebase all create the id inside their own
sign-up call, so at verdict time there is no `external_user_id` to send.
Omit it — then **attach it right after the provider mints it**:

```
const result = await portreeve.verdict({ event_type: "signup", email, ip, ... });
if (result.verdict === "block") return reject();
const user = await createTheUser();                    // provider mints the id
if (!result.degraded) await portreeve.attachUser(result.id, user.id);
```

Portreeve keeps the identity mapping. Also persist your own action context
under `result.id` (the signup and created user in this example) so webhook
enforcement can target the original action. A later
[`review.resolved` webhook](/reference/handling-verdicts#the-reviewresolved-webhook) names
your user id exactly as if you had sent it up front (and carries the event's
email as a fallback). The binding is write-once: replaying the same value is
idempotent; a different value is a `409 conflict`. Like `feedback()`,
`attachUser()` throws on failure — an unbound account would make a later
denial unenforceable, so treat a throw as a bug to fix, not noise.

### OAuth and social sign-in

"Sign in with Google" creates the account inside the provider's redirect —
by the time your callback route runs, the account exists. Screen **in the
callback** instead: fire the `signup` verdict there (this path *does* know the
user id — send it), and on `block`, disable the just-created account and do
not issue session cookies. On JWT-based auth systems "end its sessions" is
best-effort: banning kills refresh, but already-issued access tokens live
until they expire.

### No server signup route at all

If your frontend calls the auth provider directly from the browser, you must
first introduce a server signup route — the verdict call needs your secret key
and the client's real IP, and neither belongs in a browser. Budget honestly:
that hop touches session cookies, email-confirmation flows, and whatever
analytics ride on signup. It is a small rearchitecture, not a drop-in.

## Client options and failure behavior

`new Portreeve(secretKey, options?)`:

| Option | Default | Meaning |
|---|---|---|
| `timeoutMs` | `400` | Total budget for a verdict call. |
| `failMode` | `"allow"` | What you get if Portreeve times out or errors: `"allow"` returns an allow verdict, `"block"` returns a block verdict, `"throw"` throws a `PortreeveError`. |
| `baseUrl` | `https://api.portreeve.com` | Override for testing. |

### Fail-open by default

With `failMode: "allow"` (or `"block"`), timeouts, network failures, HTTP 5xx,
and invalid responses degrade to your chosen verdict. **HTTP 4xx still throws
`PortreeveError`**, including invalid/revoked keys (401) and rate limits (429).
Serialization errors also throw. `failMode: "throw"` throws on outages too.
A synthesized result is marked:

- `degraded: true`;
- `id` and `policy_version` are `null` — there is no server event behind them,
  so **guard on `result.degraded` before persisting `result.id`** (both
  quickstarts above show the pattern);
- the reasons carry a `*_failopen` code (`timeout_failopen`,
  `network_failopen`, … — see
  [SDK-synthesized codes](/reference/reason-codes#sdk-synthesized-codes)), so your logs
  can tell a real verdict from a fail-open one.

A wrong or revoked secret key raises an error; it does not silently allow traffic.
Handle and alert on 401 separately from degraded results. For 429, back off before
retrying with the same action key. Log `degraded: true` results to monitor outages.

When (and whether) to switch checkout to fail-closed:
[Handling verdicts](/reference/handling-verdicts#fail-open-and-when-to-fail-closed).

### Safe retries

Use `dedupeKey` derived from the logical action: a signup email, checkout order ID,
or login attempt ID. Identical serialized bodies replay for 24 hours; changed
context is re-screened under a stable action-and-body key, so retries of that
changed body also replay. All attempts share one `timeoutMs` budget.

For strict replay, pass `idempotencyKey` derived from a persisted attempt ID.
Changing its body throws 409; it is not automatically re-screened. Never share
one constant key across unrelated signups. Details under
[Idempotency](/reference/rest-api#idempotency-and-409-idempotency_conflict).

## Next

[What to send for each event type](/integration/event-payloads) — the canonical
payload per event, and the fields whose absence silently switches signals off.