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:

1import type { VerdictRequest } from "@portreeve/contract";
2
3// One canonical payload per event type, parsed against the real contract schema in tests.
4
5export const signup: VerdictRequest = {
6 event_type: "signup",
7 external_user_id: "usr_9f2c",
8 email: "jane@example.com",
9 ip: "203.0.113.7",
10 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
11 device_token: "dtk_2ZqX...", // from @portreeve/browser — see fingerprint setup
12 identity: { phone: "+15550100" }, // open key set: phone, payer_wallet, github_handle, ...
13};
14
15export const trialStart: VerdictRequest = {
16 event_type: "trial_start",
17 external_user_id: "usr_9f2c",
18 ip: "203.0.113.7",
19 device_token: "dtk_2ZqX...",
20};
21
22export const trialConvert: VerdictRequest = {
23 // Usually fired from a payment webhook — no client request, so ip is optional here only.
24 event_type: "trial_convert",
25 external_user_id: "usr_9f2c",
26 email: "jane@example.com",
27};
28
29export const checkoutAttempt: VerdictRequest = {
30 event_type: "checkout_attempt",
31 external_user_id: "usr_9f2c",
32 ip: "203.0.113.7",
33 device_token: "dtk_2ZqX...",
34 payment: {
35 card_fingerprint: "Xt5EWLLDS7FJjR1c", // Stripe: payment_method.card.fingerprint
36 card_funding: "prepaid", // Stripe: payment_method.card.funding
37 amount: 2900, // minor units (cents)
38 currency: "usd",
39 },
40};
41
42export const login: VerdictRequest = {
43 // The account baseline needs both: external_user_id (whose history) + device_token (which device).
44 event_type: "login",
45 external_user_id: "usr_9f2c",
46 ip: "203.0.113.7",
47 device_token: "dtk_2ZqX...",
48};
Fieldsignuptrial_starttrial_convertcheckout_attemptlogin
iprequiredrequiredoptionalrequiredrequired
external_user_idstrongly recommendedstrongly recommendedstrongly recommendedstrongly recommendedeffectively required
emailstrongly recommendedif handyif handyif handy
user_agentrecommendedrecommendedrecommendedrecommended
device_tokenrecommendedrecommendedeffectively requiredeffectively required
identity{}whatever you havewhatever 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.

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.fingerprintcard_fingerprint, payment_method.card.fundingcard_funding. amount is integer minor units; currency is a 3-letter code (any case; we normalize). There is no card-issuing-country field to send yet — see the note under velocity_card_countries.

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 but never rejected.
  • 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.