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