Skip to navigation

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:

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...",
};
Fieldsignuptrial_starttrial_convertcheckout_attemptlogin
iprequiredrequiredoptionalrequiredrequired
external_user_idstrongly recommendedstrongly recommendedstrongly recommendedstrongly recommendedeffectively required
emailstrongly recommendedif handyif handyif handyrecommended
user_agentrecommendedrecommended—recommendedrecommended
device_tokenrecommendedrecommended—effectively requiredeffectively 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 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 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; 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:

SignalNeedsIf the field is absent
card_probe_patternpayment.amountThe 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_cardspayment.card_fingerprintThe 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 laddersdevice_tokenEvery device-keyed counter reads 0. See 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 — the browser snippet that keeps the device-keyed rules alive. Not optional if you take payments.