Device fingerprinting

This step is not optional. It is the one place in this guide where skipping a step silently weakens enforcement rather than merely losing a signal. Two block-strength rules read a device-keyed counter, and both read 0 for a partner without the browser snippet:

RuleWith the snippetWithout it
Card rotation at ordinary amountssix different cards from one device in 24h → blockTops out at review. The per-IP count reviews at any magnitude, forever, by design — shared egress (office NAT, CGNAT, hotel and campus Wi-Fi) makes per-IP card counts untrustworthy.
$0 card validation (Stripe setup-mode SetupIntent, card-on-file)Judged on distinct cards per device in 10 minutesNo $0 probe gate at all.

Small non-zero probe runs still block on the per-IP arm, and every non-card signal is unaffected — but if you take payments, install the snippet before you go live. Signup abuse also rotates IPs and emails cheaply; devices are stickier.

Install the snippet

The snippet collects fingerprint components and exchanges them (using your publishable key) for a signed, short-lived device_token — all fusion and hashing happens server-side.

$npm install @portreeve/browser
1// Browser-side. Uses your PUBLISHABLE key (pk_...) — never the secret key.
2import { collectDeviceToken } from "@portreeve/browser";
3
4// Resolves to null (never throws) when collection is blocked — send without a token then.
5const device_token = await collectDeviceToken("pk_live_...");
6
7// Tokens are short-lived: collect close to the screened action, not at page load.
8await fetch("/api/signup", {
9 method: "POST",
10 headers: { "content-type": "application/json" },
11 body: JSON.stringify({
12 email: emailInput.value,
13 device_token: device_token ?? undefined, // omit when null
14 }),
15});
16
17// --- stand-in for your own page code ---
18declare const emailInput: HTMLInputElement;

collectDeviceToken resolves to null instead of throwing when collection is blocked (privacy tooling, script blockers) — send the verdict request without a token in that case; device signals simply won’t fire for that event. Never hard-require the token; that just trains real users’ browsers to fail.

A bad device_token is dropped silently — the verdict is still 200

This is the answer to “why aren’t my device signals firing?” more often than anything else on this page. A token that is expired, tampered with, or minted by a different tenant or mode is discarded before enrichment, and the verdict call proceeds exactly as if you had sent no token: 200, a normal verdict, no error, no warning field. Deliberate — a bad device token must never reject or degrade a verdict — but it makes a broken fingerprint integration invisible from the response alone.

The three causes, most common first:

  1. Token expired. Tokens live 15 minutes from minting (expires_at comes back from POST /v1/device) — the usual mistake is collecting at page load and submitting much later. Collect close to the screened action.
  2. Mode mismatch. A pk_test_ token sent with an sk_live_ call — minting key and verdict key must be the same mode.
  3. Re-encoding. Your own frontend transformed the token before forwarding it.

How to check yours are landing: fire a signup in test mode from a real browser, then open the event in the dashboard — a token that verified shows device-keyed facts populated; a dropped one shows them at 0.

Next

Test mode and feedback — verify the integration end to end, go live, and report outcomes back.