Skip to navigation

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 six endpoints.

First verdict

The same screening, in the two most common shapes:

// 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>;

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).

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 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?):

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"), 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), 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.

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.

Next

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