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

# What to send for each event type

The more you send, the more signals fire — but only `event_type` and (for most
events) `ip` are hard requirements. Canonical payloads:


```ts
import type { VerdictRequest } from "@portreeve/contract";

// One canonical payload per event type, parsed against the real contract schema in tests.

export const signup: VerdictRequest = {
  event_type: "signup",
  external_user_id: "usr_9f2c",
  email: "jane@example.com",
  ip: "203.0.113.7",
  user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
  device_token: "dtk_2ZqX...", // from @portreeve/browser — see fingerprint setup
  identity: { phone: "+15550100" }, // open key set: phone, payer_wallet, github_handle, ...
};

export const trialStart: VerdictRequest = {
  event_type: "trial_start",
  external_user_id: "usr_9f2c",
  ip: "203.0.113.7",
  device_token: "dtk_2ZqX...",
};

export const trialConvert: VerdictRequest = {
  // Usually fired from a payment webhook — no client request, so ip is optional here only.
  event_type: "trial_convert",
  external_user_id: "usr_9f2c",
  email: "jane@example.com",
};

export const checkoutAttempt: VerdictRequest = {
  event_type: "checkout_attempt",
  external_user_id: "usr_9f2c",
  ip: "203.0.113.7",
  device_token: "dtk_2ZqX...",
  payment: {
    card_fingerprint: "Xt5EWLLDS7FJjR1c", // Stripe: payment_method.card.fingerprint
    card_funding: "prepaid", //              Stripe: payment_method.card.funding
    amount: 2900, //                         minor units (cents)
    currency: "usd",
  },
};

export const login: VerdictRequest = {
  // The account baseline needs both: external_user_id (whose history) + device_token (which device).
  event_type: "login",
  external_user_id: "usr_9f2c",
  ip: "203.0.113.7",
  device_token: "dtk_2ZqX...",
};
```

| Field | signup | trial_start | trial_convert | checkout_attempt | login |
|---|---|---|---|---|---|
| `ip` | **required** | **required** | optional | **required** | **required** |
| `external_user_id` | strongly recommended | strongly recommended | strongly recommended | strongly recommended | **effectively required** |
| `email` | strongly recommended | if handy | if handy | if handy | recommended |
| `user_agent` | recommended | recommended | — | recommended | recommended |
| `device_token` | recommended | recommended | — | **effectively required** | **effectively required** |
| `identity{}` | whatever you have | — | — | whatever you have | — |
| `payment{}` | — | — | — | **recommended** | — |

## Field notes

### `device_token` on card events

Not "recommended" in the ordinary sense: it is the only field whose absence
removes *enforcement* rather than a signal — the two card rules that can block
on their own both read a device-keyed counter. See
[device fingerprinting](/integration/device-fingerprinting) for exactly what goes
dark. Per-request it stays optional (a browser that blocks collection must
still be able to sign up); it is your *integration* that is incomplete
without it.

### `ip` — optional only on `trial_convert`

Conversions are usually recorded in a payment-provider webhook handler, where
there is no client request — and therefore no honest client IP — in scope. The
other events happen inside a user-initiated request, so send the IP: IP-family
and velocity signals depend on it. Never send your own server's IP as a
stand-in — omit rather than lie.

### Where `login` screening goes

**After the credential check, before you grant the session.** The ordering is
the point, not a nicety:

- *Pre-auth is wrong*: before the password or OAuth exchange succeeds, "who is
  logging in" is an unverified claim — anyone can type anyone's email. You
  would be baselining impostors into real accounts' histories, and handing
  attackers both a verdict oracle and a lockout tool (deliberately tripping
  `block` against a victim's email).
- *Post-session is too late*: once the browser holds session cookies there is
  nothing left to enforce.

So: let your auth provider verify the credential, fire the `login` verdict
with the now-verified `external_user_id`, and only issue the session on
`allow`/`review`. On `block`, deny **that session only** — don't ban the
account: an account-takeover block means the attacker is at the door, and a
ban locks out the real owner too. A `challenge`
[recommended action](/reference/handling-verdicts#recommended_action--advice-that-rides-with-the-verdict)
means step up (MFA, re-auth) instead of refusing.

For a non-degraded result, persist the issued session's identifier and event
family under `result.id`. If a later review is denied, revoke that session only.
The webhook does not include a session identifier; `external_user_id` or email
alone cannot distinguish a suspicious login from the owner's other sessions.
If your auth stack cannot revoke an individual session, establish an explicit
session enforcement strategy before enabling asynchronous login review.

### `email` on `login`

Every login mechanism just proved control of an identifier — you always have
the email, so send it. It is a hard identity key: it stitches the login into
the account's cluster even when the device is new and the IP has rotated,
which is exactly the situation login screening exists for. (Older guidance
marked it unnecessary on the assumption `external_user_id` was always
present; send both when you can.)

### `identity{}` — an open key set

Any `string → string` map: `phone`, `payer_wallet`, `github_handle`, whatever
identifiers your product collects. Unrecognized keys are stored and used for
identity linking; you don't need to ask us before sending a new one.

### `payment{}` — pass through from Stripe

`payment_method.card.fingerprint` → `card_fingerprint`,
`payment_method.card.funding` → `card_funding`,
`payment_method.card.country` → `card_country` (feeds
[`velocity_card_countries`](/reference/reason-codes#velocity-signals); needs SDK ≥ 0.3.1).
`amount` is integer minor units; `currency` is a 3-letter code (any case; we
normalize).

## Signals that go dark without a field

An absent field is "unknown", and unknown never fires a rule — so a missing
field looks exactly like clean traffic. The three that catch people out:

| Signal | Needs | If the field is absent |
|---|---|---|
| `card_probe_pattern` | `payment.amount` | The whole card-probe ladder is skipped. A null amount is *unknown*, not *small*, so no probe rung can trip — the event falls through to the ordinary-amount `velocity_card_attempts` ladder instead. Send `currency` too: it selects the probe-sized ceiling per currency, and omitting it applies the two-decimal default to every currency (wrong for JPY, KRW, VND, CLP, ISK, BHD, KWD, OMR). |
| `velocity_distinct_cards` | `payment.card_fingerprint` | The event never enters the distinct-card sets at all, for itself *or* for later events. The counters stay at 0 and no rung ever trips. |
| `velocity_device_signups`, and the device rungs of the card ladders | `device_token` | Every device-keyed counter reads 0. See [device fingerprinting](/integration/device-fingerprinting) — this is not a small loss. |

## Strict requests, tolerant emails

- **Unknown top-level keys are rejected** with a 400 `invalid_request` — an
  integration typo fails loudly in test mode instead of silently dropping a
  signal. Anything free-form belongs in `context{}`, which is stored and echoed
  back and never read by rules — but it is bounded: at most 32 keys, keys up
  to 64 chars, 8 KB serialized total. It is a note field, not a document
  store.
- **Email is barely validated at the API edge** (length + `@`). On purpose: the
  engine judges email quality and reports it in reason codes; the schema just
  transports what your user typed. Don't pre-filter emails before sending them.

## Next

[Device fingerprinting](/integration/device-fingerprinting) — the browser snippet
that keeps the device-keyed rules alive. Not optional if you take payments.