> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.portreeve.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.portreeve.com/_mcp/server.

# Handling verdicts

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

| Verdict | What you do | When it happens |
|---|---|---|
| `allow` | Proceed. | The overwhelming majority of traffic. |
| `review` | **Proceed** — and be ready to revoke later via webhook. | Accumulated weak signals; worth human eyes, not worth blocking a possibly-real user. |
| `block` | Stop 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.

## `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: `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:

```ts
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 review | On `denied` |
|---|---|
| `signup` | Disable the account, end its session(s) — best-effort on JWT-based auth: banning kills refresh, but already-issued access tokens live until expiry. |
| `trial_start` | Revoke the reviewed trial access. |
| `trial_convert` | Cancel the subscription; refund per your policy. |
| `checkout_attempt` | One of the two patterns below. |
| `login` | Revoke only the screened session; do not ban the account owner. |

## Checkout: two enforcement patterns

| | Pattern A — capture + refund | Pattern B — manual capture |
|---|---|---|
| Money on a deny | Moves, then moves back (refund) | Never moved (hold released) |
| Operational cost | Lowest — nothing changes for un-reviewed charges | Every charge needs an explicit capture step |
| Time pressure | None | Authorizations expire in ~7 days |
| Choose it when | Default — use this unless you have a reason not to | Refund 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.

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

```ts
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](/reference/reason-codes#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](/integration/test-mode-feedback#reporting-outcomes-back-feedback)
and the [reason-code reference](/reference/reason-codes) for how confirmed abuse
propagates across an abuser's linked accounts.