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

# Reason codes

Every verdict carries `reasons[]` — the codes that explain it. The **codes**
are public, stable contract; the thresholds and logic that fire them are not
public and will keep evolving without notice. Some reasons carry a small
`detail` object of scalars (e.g. `identity_cluster` includes
`{"linked_accounts": 4}`).

> **You will eventually receive codes not on this page.** The enum is
> tolerant by design — new detection signals ship as new codes without a new
> SDK release (the SDK types them as `KnownCode | string`). Treat any unknown
> code as **informational only**: log it, display it, never crash on it, and
> never map "unknown" to a punitive action. The verdict field is the decision;
> reasons are the explanation.

## Email signals

| Code | Meaning | Typical action |
|---|---|---|
| `disposable_email` | Email domain is a known disposable/burner provider. | Usually arrives with `review`. If trials are being farmed, consider requiring a non-disposable email at product level. |
| `invalid_mx` | Email's domain can't receive mail (no valid MX). | Treat the address as unverifiable; hold trial credits behind email verification. |
| `young_domain` | Email's domain was registered very recently. | Weak signal on its own — meaningful in combination. No action alone. |
| `malformed_email` | The engine couldn't parse/normalize the address. | If real users trigger this, check what your form is sending us. |

## IP signals

| Code | Meaning | Typical action |
|---|---|---|
| `proxy_ip` | IP is a known commercial proxy/VPN exit. | Don't punish alone — plenty of real users run VPNs. Strengthens other signals. |
| `tor_exit` | IP is a Tor exit node. | Same: combined-signal, not a standalone conviction. |
| `datacenter_ip` | IP belongs to a hosting/datacenter ASN, not a consumer network. | Characteristic of scripted traffic; expect it alongside velocity codes during attacks. |

## Velocity signals

| Code | Meaning | Typical action |
|---|---|---|
| `velocity_ip_signups` | Abnormal signup rate from this IP. | Attack-wave indicator; watch the dashboard for the spike. |
| `velocity_device_signups` | Abnormal signup rate from this device. | Stronger than IP velocity (devices rotate slower than IPs). **Requires a verified `device_token`** — see [device fingerprinting](/integration/device-fingerprinting). |
| `velocity_domain_signups` | Burst of signups sharing one email domain. | Scripted signup wave or a company rollout — the review queue will make it obvious which. |
| `velocity_card_attempts` | Many checkout attempts on the **same** card fingerprint. | Usually a real buyer having a bad time — a 3DS loop, a mistyped CVC, dunning retries. It reviews; it never blocks on its own. Consider [manual capture](/reference/handling-verdicts#pattern-b--stripe-manual-capture-authorize-now-capture-on-approve) while it lasts. |
| `velocity_ip_card_attempts` | Many checkout attempts from one IP. Counts attempts, **not** distinct cards — a busy office NAT produces it too. | Corroborating only. Look for `velocity_distinct_cards` on the same events to tell a card-tester from a busy shared IP. |
| `velocity_distinct_cards` | Many **different** card fingerprints seen from one IP (1h) or one device (24h). | The card-testing signature. A single device presenting six different cards in a day has no ordinary explanation; the same count from one IP may be a coworking space or a family, so the IP form never blocks on its own. **Requires `payment.card_fingerprint`** — an event sent without one never enters the distinct-card sets, so this code can never fire for you. |
| `velocity_card_countries` | The linked accounts behind this user have presented cards issued in **three or more different countries** in 30 days. | Behavioural, not demographic — a customer living abroad has *two* issuing countries and never triggers this. It reviews and never blocks on its own, because two people sharing a device can be linked into one identity and pool their cards. Detail carries `{cluster_card_countries_30d, threshold}`. **Not reachable yet — see below.** |
| `velocity_cluster_trials` | One linked identity repeatedly starting trials. | Trial multiplexing. Deny in review unless you recognize the cluster as legitimate (agencies, teams). |

> **`velocity_card_countries` needs `payment.card_country`.** It counts
> distinct card-**issuing** countries per device and per account. Send Stripe's
> `payment_method.card.country` straight through as `payment.card_country`
> (ISO-3166-1 alpha-2, any case — we normalize). Sendable from Node SDK
> `portreeve@0.3.1`; on older SDK versions the field is absent from the
> `Payment` type and the counter never moves. As always, an absent field is
> *unknown* and the rule simply stays silent for you.
>
> **If you are asking because you want this signal: it is not available, no
> configuration turns it on, and we will tell you when it ships.**

## Identity signals

| Code | Meaning | Typical action |
|---|---|---|
| `identity_cluster` | This identity links to existing accounts (`detail.linked_accounts`). | Open the cluster in the dashboard. Households, teams, and agencies are legitimate multi-account clusters — that's exactly what review is for. |
| `cluster_confirmed_abuse` | The linked cluster contains confirmed abuse (from your feedback or a review denial). | Block-strength. Deny fast in review; the graph is doing its job. |
| `cluster_trusted` | The cluster was marked trusted (by you or by us). | Informational; pushes toward `allow`. |

## Account signals (`login` only)

These read one account's own history rather than volume across a shared identifier, so they need both `external_user_id` and `device_token` on the request. Send a `login` without either and neither can fire.

Neither fires for an account we hold no history for. A first-ever login — and every login while our cache is cold — produces *no* account signal rather than a false one. The absence of history is never treated as evidence.

| Code | Meaning | Typical action |
|---|---|---|
| `ato_new_device` | This account signed in from a device it has not used before, on an account we do have history for (`detail.known_devices_30d`). | Usually a new laptop or phone. Pair with `recommended_action: "challenge"` and ask for a second factor rather than refusing. |
| `ato_account_device_burst` | Many separate devices signed in to this one account in quick succession (`detail.devices_10m`). | A shared or stuffed credential. Step up, and consider ending the account's other sessions. |

Devices accumulating on one account *gradually* is seat sharing, not takeover — a commercial signal rather than an abuse one. It is reported in the dashboard and never returned as a verdict.

## Payment signals

| Code | Meaning | Typical action |
|---|---|---|
| `prepaid_card` | Card funding type is prepaid. | Common in trial abuse and card testing, but many legitimate customers use prepaid cards — it's a combined-signal, and your feedback calibrates it. |
| `card_probe_pattern` | Attempt shape matches card-validation probes: small amounts, rapid, from one source — whether the same card is retried or a **fresh card is used each time**. | Two such attempts in ten minutes `review`; **three or more `block` on this signal alone.** It is the one card signal that convicts without corroboration — a run of tiny authorizations has no legitimate retry story. **Requires `payment.amount`** (see below). If you sell at sub-1.00 price points, email [admin@portreeve.com](mailto:admin@portreeve.com) so the probe ceiling fits your prices. Refund any probes that settled before detection, before they become disputes. |

> **`card_probe_pattern` needs an amount, and a `0` amount is judged
> differently.** An absent `payment.amount` is *unknown*, not *small*: with no
> amount the probe ladder is skipped entirely and the attempt falls through to
> `velocity_card_attempts` instead, which never blocks alone. Send `amount`
> (minor units) and `currency` on every checkout and trial-conversion event —
> `currency` selects the probe-sized ceiling, so omitting it applies the
> two-decimal default to currencies where that is the wrong scale.
>
> A **zero** amount is probe-sized in every currency — a $0 authorization is a
> setup-mode `SetupIntent`, which is both how everyone stores a card on file and
> the purest card-validation move there is. Because those two look identical, a
> zero-amount event is judged **only** on how many *different* cards one device
> presented in ten minutes: someone fumbling one card on your signup form is a
> frustrated customer and is not a probe, however many times they try.
>
> The $0 ladder therefore runs on its own numbers, deliberately looser than the
> two-and-three above: **one or two cards allow, three or four `review`, five or
> more `block`**. Three is where the legitimate case ends — a person carries
> three or four cards, and a customer working through personal, corporate and
> backup after declines must get a human look rather than a block. A real card
> tester presents tens to hundreds; the volume *is* the economics.
>
> That arm reads a device counter, so a $0 event with no `device_token`
> ([fingerprint snippet](/integration/device-fingerprinting)) has no probe
> gate at all.

## Your lists

| Code | Meaning | Typical action |
|---|---|---|
| `allowlisted` | Matched your allowlist — verdict forced to `allow`. | None. Manage lists in dashboard Settings. |
| `blocklisted` | Matched your blocklist — verdict forced to `block`. | None. Manage lists in dashboard Settings. |

## SDK-synthesized codes

These codes are attached by the **SDK on your side, never by the server**:
they mark a verdict the SDK synthesized locally (per your `failMode`) because
it couldn't get a real one. Such results also carry `degraded: true`, and
their `id` and `policy_version` are `null` — there is no server event behind
them. The `*_failopen` suffix is reserved for these client-side degradation
codes; only `timeout_failopen` appears in the frozen enum, the others arrive
via the tolerant `KnownCode | string` type.

| Code | Meaning | Typical action |
|---|---|---|
| `timeout_failopen` | The verdict call exceeded `timeoutMs`; failMode produced this verdict. | Should be ~zero. If you see these regularly, tell us — it's our latency problem, not your integration. |
| `network_failopen` | The SDK couldn't reach the API at all (DNS, connect, TLS). | Check your egress/firewall first, then tell us. |
| `upstream_failopen` | The API answered with a 5xx. | Our problem — tell us if it persists beyond a blip. |
| `invalid_response_failopen` | The API response didn't parse as a verdict. | Almost certainly our bug — report it with a timestamp. |

## Meta

| Code | Meaning | Typical action |
|---|---|---|
| `partial_enrichment` | The verdict was decided within its latency budget before some slower lookups finished. | Informational. The stored event is upgraded asynchronously; future decisions about the same actors use the completed data. |