Handling verdicts
Portreeve returns exactly three verdicts. Two are simple; the third is the one worth reading this page for.
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 reservingblockfor 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 onreview, you’ve rebuiltblockwith extra steps and inherited all the false-positive pain the design exists to avoid. - Treat
reviewasallowat request time, plus two obligations:- persist
result.id(and your user id) so the event is findable later; - handle the
review.resolvedwebhook and be able to revoke.
- persist
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.
recommended_action — advice that rides with the verdict
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:loginevents) — 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 staysalloworreviewbecause review never holds the flow —challengeis 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:
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
Checkout: two enforcement patterns
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.
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.
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
idandpolicy_versionarenull, so guard ondegradedbefore persisting the id. - Monitor the
degradedrate 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.