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

# 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:

| Rule | With the snippet | Without it |
|---|---|---|
| Card rotation at ordinary amounts | six different cards from one device in 24h → `block` | Tops 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 minutes | No $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.

```bash
npm install @portreeve/browser
```


```ts
// Browser-side. Uses your PUBLISHABLE key (pk_...) — never the secret key.
import { collectDeviceToken } from "@portreeve/browser";

// Resolves to null (never throws) when collection is blocked — send without a token then.
const device_token = await collectDeviceToken("pk_live_...");

// Tokens are short-lived: collect close to the screened action, not at page load.
await fetch("/api/signup", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    email: emailInput.value,
    device_token: device_token ?? undefined, // omit when null
  }),
});

// --- stand-in for your own page code ---
declare 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.

**Budget the wait**: the default collection timeout is 5000 ms, and because
you collect at submit, a blocked or slow browser spends that long between the
user clicking "Create account" and your request firing. `timeoutMs: 2500` is a
reasonable compromise for a signup form; go lower if your p95 matters more
than the occasional lost token.

## 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](/integration/test-mode-feedback) — verify the integration
end to end, go live, and report outcomes back.