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
Livesk_live_…
Testsk_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
Livepk_live_…
Testpk_test_…

Used only to mint device tokens. Safe to ship to the browser. See device fingerprinting.

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

Install

$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 — the API is four endpoints.

First verdict

The same screening, in the two most common shapes:

1// app/api/signup/route.ts
2import { Portreeve } from "portreeve";
3
4const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
5
6export async function POST(req: Request) {
7 const { email, device_token } = (await req.json()) as {
8 email: string;
9 device_token?: string;
10 };
11
12 // Behind a proxy/CDN (Vercel, Cloudflare, Railway) this header is set.
13 const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
14 if (!ip) return Response.json({ error: "no_client_ip" }, { status: 400 });
15
16 // Screen BEFORE creating the account, with the id you're about to use.
17 const userId = crypto.randomUUID();
18 const result = await portreeve.verdict({
19 event_type: "signup",
20 external_user_id: userId,
21 email,
22 ip,
23 user_agent: req.headers.get("user-agent") ?? undefined,
24 device_token,
25 });
26
27 if (result.verdict === "block") {
28 return Response.json({ error: "signup_rejected" }, { status: 403 });
29 }
30
31 // "review" proceeds. A degraded (fail-open) result has no server event — its id is null.
32 await createUser({
33 id: userId,
34 email,
35 portreeveEventId: result.degraded ? null : result.id,
36 });
37
38 return Response.json({ ok: true });
39}
40
41// --- stand-in for your own app code ---
42declare function createUser(user: {
43 id: string;
44 email: string;
45 portreeveEventId: string | null;
46}): Promise<void>;

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

new Portreeve(secretKey, options?):

OptionDefaultMeaning
timeoutMs400Total 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.
baseUrlhttps://api.portreeve.comOverride for testing.

Fail-open by default

With failMode: "allow" (or "block"), .verdict() never throws — an outage on our side degrades to your chosen verdict instead of breaking your signup or checkout. This is deliberate: your revenue path matters more than any single verdict. 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), so your logs can tell a real verdict from a fail-open one.

When (and whether) to switch checkout to fail-closed: Handling verdicts.

Safe retries

Retrying a verdict call (or replaying a job): pass { idempotencyKey: "signup-usr_9f2c" } — retries with the same key return the original verdict and don’t double-count velocity signals. Details under Idempotency.

Next

What to send for each event type — the canonical payload per event, and the fields whose absence silently switches signals off.