Handling verdicts

Portreeve returns exactly three verdicts. Two are simple; the third is the one worth reading this page for.

VerdictWhat you doWhen it happens
allowProceed.The overwhelming majority of traffic.
reviewProceed — and be ready to revoke later via webhook.Accumulated weak signals; worth human eyes, not worth blocking a possibly-real user.
blockStop the flow with a generic error.High-confidence signals only.

Review never blocks

This is the load-bearing design decision, quoted from our design spec so there’s no ambiguity about what you’re integrating against:

Review NEVER holds the user’s flow — it is allow-with-a-flag + queue entry + notification. Signup/trial: account proceeds; deny resolution fires review.resolved (verdict flipped to block) and the partner’s webhook handler revokes. Checkout: charge proceeds; documented patterns are (a) capture + refund on deny, (b) Stripe manual capture — auth now, capture on approve, release on deny. Because review is free for the end user, the engine can flag aggressively while reserving block for high-confidence signals — that’s the false-positive discipline mechanism.

What this means for your code:

  • Never show a “your account is under review” screen, hold an email verification hostage, or delay checkout on review. If you gate users on review, you’ve rebuilt block with extra steps and inherited all the false-positive pain the design exists to avoid.
  • Treat review as allow at request time, plus two obligations:
    1. persist result.id (and your user id) so the event is findable later;
    2. handle the review.resolved webhook and be able to revoke.

Reviews are resolved by your team in the dashboard Review queue. An approve requires nothing from your systems — the user was never blocked. A deny fires the webhook below.

The review.resolved webhook

Configure your webhook URL in dashboard Settings (there’s a test-send button). Deliveries are signed with your webhook secret (whsec_...), shown in Settings → Webhook alongside the URL: the Portreeve-Signature header carries sha256=<hex> — the HMAC-SHA256 of the raw request body. Always verify; the SDK does it in one call, with a timing-safe compare:

1import express from "express";
2import { Portreeve } from "portreeve";
3
4const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
5const app = express();
6
7// Verify against the RAW body: mount express.raw() on this route only.
8app.post(
9 "/webhooks/portreeve",
10 express.raw({ type: "application/json" }),
11 async (req, res) => {
12 let event;
13 try {
14 event = portreeve.verifyWebhook(
15 (req.body as Buffer).toString("utf8"),
16 req.get("portreeve-signature") ?? "",
17 process.env.PORTREEVE_WEBHOOK_SECRET!
18 );
19 } catch {
20 res.status(400).send("bad signature");
21 return;
22 }
23
24 if (event.type === "review.resolved" && event.resolution === "denied") {
25 // Deny = revoke now. Deliveries retry — keep both calls idempotent.
26 if (event.external_user_id) {
27 await disableAccount(event.external_user_id); // signup / trial deny
28 }
29 await refundIfCaptured(event.event_id); // checkout deny (capture+refund pattern)
30 }
31
32 // "approved" needs nothing — the user was never blocked.
33 res.status(200).send("ok");
34 }
35);
36
37// --- stand-ins for your own app code ---
38declare function disableAccount(externalUserId: string): Promise<void>;
39declare function refundIfCaptured(portreeveEventId: string): Promise<void>;

Operational notes:

  • Verify against the raw bytes. If a JSON body parser runs first, verification fails on re-serialized whitespace. Mount express.raw() (or your framework’s equivalent) on this route specifically.
  • Deliveries retry with backoff until your endpoint returns 2xx, so your revocation must be idempotent — revoking an already-revoked account is a no-op, not an error.
  • Respond 2xx quickly; do slow work (refunds, emails) async if needed.

What “revoke” means per event family

Flow under reviewOn denied
signupDisable the account, end its session(s).
trial_startEnd the trial, disable the account.
trial_convertCancel the subscription; refund per your policy.
checkout_attemptOne of the two patterns below.

Checkout: two enforcement patterns

Pattern A — capture + refundPattern B — manual capture
Money on a denyMoves, then moves back (refund)Never moved (hold released)
Operational costLowest — nothing changes for un-reviewed chargesEvery charge needs an explicit capture step
Time pressureNoneAuthorizations expire in ~7 days
Choose it whenDefault — use this unless you have a reason not toRefund optics matter, or you’re actively being card-tested

Pattern A — capture normally, refund on deny (default)

Capture as normal; on a denial, refund. A prompt proactive refund is dramatically cheaper than the alternative: a dispute costs ~$15 even when you win, and dispute count — not just rate — is what flags your Stripe account.

1import Stripe from "stripe";
2import { Portreeve } from "portreeve";
3
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
5const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
6
7export async function handleCheckout(opts: {
8 userId: string;
9 ip: string;
10 deviceToken?: string;
11 paymentMethodId: string;
12 amount: number; // minor units
13 currency: string;
14}) {
15 const pm = await stripe.paymentMethods.retrieve(opts.paymentMethodId);
16
17 const result = await portreeve.verdict({
18 event_type: "checkout_attempt",
19 external_user_id: opts.userId,
20 ip: opts.ip,
21 device_token: opts.deviceToken,
22 payment: {
23 card_fingerprint: pm.card?.fingerprint ?? undefined,
24 card_funding: (pm.card?.funding ?? "unknown") as
25 | "credit"
26 | "debit"
27 | "prepaid"
28 | "unknown",
29 amount: opts.amount,
30 currency: opts.currency,
31 },
32 });
33
34 if (result.verdict === "block") {
35 throw new Error("checkout_rejected"); // surface a generic decline to the user
36 }
37
38 const intent = await stripe.paymentIntents.create({
39 amount: opts.amount,
40 currency: opts.currency,
41 payment_method: opts.paymentMethodId,
42 confirm: true,
43 // The webhook handler finds this charge by event id on a deny (null on a degraded result).
44 metadata: { portreeve_event_id: result.id },
45 });
46
47 return { intent, underReview: result.verdict === "review" };
48}
49
50// Called from your Portreeve webhook handler on resolution === "denied":
51export async function refundIfCaptured(portreeveEventId: string) {
52 const intents = await stripe.paymentIntents.search({
53 query: `metadata["portreeve_event_id"]:"${portreeveEventId}"`,
54 });
55 for (const intent of intents.data) {
56 if (intent.status === "succeeded") {
57 await stripe.refunds.create({ payment_intent: intent.id }); // already-refunded intents error harmlessly
58 }
59 }
60}

Pattern B — Stripe manual capture (authorize now, capture on approve)

Authorize now, move money only after review. On a deny you cancel the authorization and no money ever moved — the customer sees a released hold, not a charge + refund.

1import Stripe from "stripe";
2
3const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
4
5export async function authorizeCheckout(opts: {
6 amount: number;
7 currency: string;
8 paymentMethodId: string;
9 portreeveEventId: string; // from the verdict call you made just before
10}) {
11 return stripe.paymentIntents.create({
12 amount: opts.amount,
13 currency: opts.currency,
14 payment_method: opts.paymentMethodId,
15 confirm: true,
16 capture_method: "manual",
17 metadata: { portreeve_event_id: opts.portreeveEventId },
18 });
19}
20
21// verdict === "allow" → capture immediately:
22export async function captureNow(intentId: string) {
23 await stripe.paymentIntents.capture(intentId);
24}
25
26// verdict === "review" → leave authorized; your review.resolved webhook decides:
27export async function onReviewResolved(intentId: string, resolution: "approved" | "denied") {
28 if (resolution === "approved") {
29 await stripe.paymentIntents.capture(intentId);
30 } else {
31 await stripe.paymentIntents.cancel(intentId); // releases the hold — nothing to refund
32 }
33}

Trade-offs to accept before choosing B:

  • Every charge — not just reviewed ones — needs an explicit capture step.
  • Uncaptured authorizations expire in ~7 days. Keep your team’s review turnaround well inside that window (aim for ≤ 1 business day).
  • Some payment methods don’t support manual capture.

Choose B when refund optics matter (high ticket sizes, B2B invoicing customers who escalate over any charge) or when you’re actively being card-tested and want zero settled fraudulent charges.

Fail-open, and when to fail closed

The SDK defaults to fail-open: if Portreeve is slow or down, your traffic gets allow with degraded: true and a *_failopen reason code (timeout_failopen, network_failopen, … — see SDK-synthesized codes), and your signup and checkout keep working. We built it this way on purpose — an abuse filter that can take down your revenue path is a worse abuser than the abusers.

  • A degraded result has no server event behind it — its id and policy_version are null, so guard on degraded before persisting the id.
  • Monitor the degraded rate in your logs. It should be ~zero; if it isn’t, tell us before you tune anything.

When fail-closed is worth it

Consider failMode: "block" only where a wrongly-blocked legitimate action is cheaper than what gets through — in practice: on checkout_attempt, while you are under an active card-testing attack, where minutes of blocked checkouts beat thousands of $0.50 auth fees and a Stripe account review. signup and trial events should essentially always fail open. failMode: "throw" is for teams that want to make the call themselves in a try/catch.

failMode is a client-side setting only

There is no server-side or per-tenant fail-mode configuration — nothing we can flip on our end changes it, because the choice is about what your code does when it cannot reach us, and by then our opinion is unreachable too. It is also per-client, not per-event type: if you want checkout_attempt to fail closed while signup fails open, construct two Portreeve clients with different failMode values and call the right one. That is the whole mechanism.

Closing the loop

Verdict quality compounds when you report outcomes — see feedback in the integration guide and the reason-code reference for how confirmed abuse propagates across an abuser’s linked accounts.