Skip to navigation

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.

Some verdicts carry an optional recommended_action. It never changes what the verdict means — ignore it and nothing breaks — but when present it is the most useful thing you can act on:

  • challenge (today: login events) — the sign-in looks like a possible account takeover (unrecognized device, odd velocity), but blocking would lock out a real traveler as often as a thief. Make the user prove it’s them: MFA prompt, email code, re-authentication. The verdict stays allow or review because review never holds the flow — challenge is how we say “proceed, but make them prove it”.
  • hold — fulfill nothing until the review resolves (the checkout patterns below show where a hold fits).

Treat unknown future values like unknown reason codes: ignore what you don’t recognize. Absence means “nothing to advise” — the server omits the field rather than sending none.

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:

import express from "express";
import { Portreeve, type ReviewResolvedEvent } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
const app = express();
// Verify against the RAW body: mount express.raw() on this route only.
app.post(
"/webhooks/portreeve",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = portreeve.verifyWebhook(
(req.body as Buffer).toString("utf8"),
req.get("portreeve-signature") ?? "",
process.env.PORTREEVE_WEBHOOK_SECRET!
);
} catch {
res.status(400).send("bad signature");
return;
}
try {
await enforceDeniedReview(event, merchantActions);
} catch {
// Missing context or failed enforcement must not acknowledge a no-op.
res.status(503).send("enforcement pending");
return;
}
// "approved" needs nothing — the user was never blocked.
res.status(200).send("ok");
}
);
// Persist this merchant-owned context under the non-degraded verdict's id
// when creating the account, trial, payment, or session. The webhook does not
// carry an event type or session id. Do not infer those from a user's email.
type ReviewContext =
| { eventType: "signup"; userId: string }
| { eventType: "trial_start" | "trial_convert"; trialId: string }
| { eventType: "checkout_attempt"; paymentId: string }
| { eventType: "login"; sessionId: string };
interface ReviewActions {
findContext(eventId: string): Promise<ReviewContext | null>;
disableAccount(userId: string): Promise<void>;
revokeTrial(trialId: string): Promise<void>;
refundIfCaptured(paymentId: string): Promise<void>;
revokeSession(sessionId: string): Promise<void>;
}
export async function enforceDeniedReview(event: ReviewResolvedEvent, actions: ReviewActions) {
if (event.resolution !== "denied") return;
const context = await actions.findContext(event.event_id);
if (!context) throw new Error("Review context is not available yet");
// All operations must be idempotent: deliveries may be repeated.
switch (context.eventType) {
case "signup":
if (context.userId) return actions.disableAccount(context.userId);
break;
case "trial_start":
case "trial_convert":
if (context.trialId) return actions.revokeTrial(context.trialId);
break;
case "checkout_attempt":
if (context.paymentId) return actions.refundIfCaptured(context.paymentId);
break;
case "login":
if (context.sessionId) return actions.revokeSession(context.sessionId);
break;
}
throw new Error("Review context is incomplete");
}
// --- implement these operations in your own app ---
declare const merchantActions: ReviewActions;

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.
  • At most six delivery attempts: the initial attempt plus retries after 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours from each preceding failure. A 2xx ends delivery. Permanent destination failures can stop earlier. Delivery timing also depends on the worker; this is not a delivery guarantee.
  • Keep enforcement idempotent. Respond 2xx only after enforcement succeeds or the work is durably queued in your own system. Missing context returns 503.
  • Monitor failed/exhausted deliveries in Settings → Webhook. After fixing the cause, reconcile the affected reviews with your own action records; contact support if you need delivery recovery. Automatic retries stop at exhaustion.

Persist the action context under result.id when creating the account, trial, payment, or session (only when result.degraded is false). The payload’s external_user_id and optional email identify a user, but do not identify the screened session or event family. The handler must use your stored context. For trial_convert, implement revokeTrial to cancel the resulting subscription and apply your refund policy. The checkout handler below uses capture + refund.

What “revoke” means per event family

Flow under reviewOn denied
signupDisable the account, end its session(s) — best-effort on JWT-based auth: banning kills refresh, but already-issued access tokens live until expiry.
trial_startRevoke the reviewed trial access.
trial_convertCancel the subscription; refund per your policy.
checkout_attemptOne of the two patterns below.
loginRevoke only the screened session; do not ban the account owner.

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.

import Stripe from "stripe";
import { Portreeve } from "portreeve";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);
export async function handleCheckout(opts: {
userId: string;
ip: string;
deviceToken?: string;
paymentMethodId: string;
amount: number; // minor units
currency: string;
}) {
const pm = await stripe.paymentMethods.retrieve(opts.paymentMethodId);
const result = await portreeve.verdict({
event_type: "checkout_attempt",
external_user_id: opts.userId,
ip: opts.ip,
device_token: opts.deviceToken,
payment: {
card_fingerprint: pm.card?.fingerprint ?? undefined,
card_funding: (pm.card?.funding ?? "unknown") as
| "credit"
| "debit"
| "prepaid"
| "unknown",
amount: opts.amount,
currency: opts.currency,
},
});
if (result.verdict === "block") {
throw new Error("checkout_rejected"); // surface a generic decline to the user
}
const intent = await stripe.paymentIntents.create({
amount: opts.amount,
currency: opts.currency,
payment_method: opts.paymentMethodId,
confirm: true,
// The webhook handler finds this charge by event id on a deny (null on a degraded result).
metadata: { portreeve_event_id: result.id },
});
return { intent, underReview: result.verdict === "review" };
}
// Called from your Portreeve webhook handler on resolution === "denied":
export async function refundIfCaptured(portreeveEventId: string) {
const intents = await stripe.paymentIntents.search({
query: `metadata["portreeve_event_id"]:"${portreeveEventId}"`,
});
for (const intent of intents.data) {
if (intent.status === "succeeded") {
await stripe.refunds.create({ payment_intent: intent.id }); // already-refunded intents error harmlessly
}
}
}

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.

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function authorizeCheckout(opts: {
amount: number;
currency: string;
paymentMethodId: string;
portreeveEventId: string; // from the verdict call you made just before
}) {
return stripe.paymentIntents.create({
amount: opts.amount,
currency: opts.currency,
payment_method: opts.paymentMethodId,
confirm: true,
capture_method: "manual",
metadata: { portreeve_event_id: opts.portreeveEventId },
});
}
// verdict === "allow" → capture immediately:
export async function captureNow(intentId: string) {
await stripe.paymentIntents.capture(intentId);
}
// verdict === "review" → leave authorized; your review.resolved webhook decides:
export async function onReviewResolved(intentId: string, resolution: "approved" | "denied") {
if (resolution === "approved") {
await stripe.paymentIntents.capture(intentId);
} else {
await stripe.paymentIntents.cancel(intentId); // releases the hold — nothing to refund
}
}

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.