# Device data collection

Three independent device signals can travel with a charge. All are optional, all improve
your approval rate, and **none can block a payment if it fails**. That means a broken
CSP or blocked collector looks exactly like a healthy integration with empty device data.
If scores look “soft” and `device.*` is empty, fix CSP first. See
[Integration hard rules §7](./27-integration-hard-rules.md#7-device-signals-fail-open).

| Signal | Where it comes from | Charge field |
|---|---|---|
| Browser fingerprint | `wp.getFingerprint()` | `device.fingerprint` |
| Kount device session | `wp.getKountSessionId()`, from Kount's collector | `device.kount_session_id` |
| Device session (the buyer's IP and browser) | `wp.getDeviceSession()` | `device_session` |

> **Using the [Payment Element](./08b-payment-element.md)? You are already covered.** The
> frame collects the first two signals and attaches them to the charge, and it confirms
> from the buyer's browser, so the charge already carries the buyer's IP. This page is for
> building the charge yourself: you read the signals in the browser, send them to your
> server, and put them on [`POST /charges`](./03-charges.md).

---

## Browser fingerprint

`wp.getFingerprint()` returns a stable per-browser id, cached in `localStorage` for 24
hours. It resolves `null` rather than throwing, and it is safe to call repeatedly: the
cached value is reused.

```js
const wp = WorthyPayments('pk_live_xxx');

const fingerprint = await wp.getFingerprint();
// POST it to your server alongside the rest of the order
```

Your server then puts it on the charge:

```json
{ "device": { "fingerprint": "tp_fp_9f2c..." } }
```

The fingerprint library is fetched lazily from a CDN the first time you call it, so a
blocked CDN yields `null`. **Charge anyway.** A device signal is enrichment, and treating
`null` as a hard failure would decline good customers over a browser extension.

---

## Kount device session

`wp.getKountSessionId()` runs Kount's Device Data Collector in the buyer's browser and
returns the session id it produced:

```js
const kountSessionId = await wp.getKountSessionId();  // null when DDC is off
```

Send it to your server with the fingerprint, and put both in the `device` block:

```json
{ "device": { "fingerprint": "tp_fp_9f2c...", "kount_session_id": "abc123..." } }
```

Our fraud engine joins the profile Kount collected to the charge using that id. A charge
with the field empty is scored without a device signal.

The collector has to run in the buyer's browser, so there is no server-side substitute.
Collect it on the page, then charge from your server.

### It never blocks a payment

Every failure path resolves to a null session id and the charge proceeds: collection
disabled for your account, an unreachable config endpoint, a collector iframe blocked by
CSP or an extension. Collection is capped at 1.5 seconds, because the `collect-end`
callback never fires when the frame is blocked, and a checkout that waits forever for a
fraud *signal* has turned an optional input into a hard dependency.

The flip side: a missing device signal is silent by design. If you expect Kount data and
see none, check your CSP first (below), then whether `GET /v1/kount-config` reports
`enabled: true` for your account.

### Configuration

The SDK reads `GET /v1/kount-config`. The endpoint is scope-free, so a publishable key
can call it:

```json
{ "object": "kount_config", "mode": "test", "client_id": "900900",
  "environment": "sandbox", "enabled": true }
```

The Kount environment follows your **key**: a test key always collects against the sandbox
tenant. `enabled: false` means your account is not provisioned for DDC, and the SDK skips
collection entirely.

**An absent `client_id` disables collection too, whatever `enabled` says.** The SDK needs
both, so `{"enabled": true, "client_id": null}` yields no session id and
`getKountSessionId()` resolves `null`. The same outcome as `enabled: false`, reached a
different way. If you expect device data and are getting `null`, read this endpoint before
assuming the collector is at fault.

---

## Device session: the buyer's IP and browser

The IP a charge is scored on (per-IP velocity, the IP blocklist, the cascade) is always the
connection we see. It is never read from the request body, because an IP a caller could name
would let anyone rotate past those rules. When **your server** sends the charge with a secret
key, the connection we see is your server, the same address for every one of your buyers. We
record that address as your server's and never count it as a buyer IP, so it cannot get your
whole store blocked, but the charge then has no buyer IP to score on.

`wp.getDeviceSession()` closes that gap, the way Stripe.js does with a Radar session. It calls
`POST /v1/device_sessions` from the buyer's browser with your publishable key; we record the IP
the request comes from, its user agent and the fingerprint, and answer an opaque id:

```js
const deviceSession = await wp.getDeviceSession();  // "ds_K7Q2…", or null
// POST it to your server with the order
```

Your server sends it as `device_session`:

```json
{ "amount": 4999, "currency": "usd",
  "payment_method": { "payment_method_id": "pm_..." },
  "initiated_by": "customer",
  "device_session": "ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3" }
```

The charge then carries the buyer's IP and user agent, and the session's fingerprint when you
sent no `device.fingerprint`.

- **Call it when the buyer pays.** A session lasts **30 minutes**. The SDK caches it and fetches
  a new one while the cached one still has ten minutes left, so a page left open still hands
  your server a live id. One request is shared by concurrent calls.
- **Reusable within those 30 minutes.** The same id may go on an idempotent retry of the charge
  or on a second card after a decline. It only ever reports the connection that created it.
- **Scoped to your account and mode.** A session created with `pk_test_…` is unknown to a live
  key, and another account's session is unknown to yours.
- **Publishable key only.** `POST /v1/device_sessions` with a secret key is a `403`: from your
  server it would record your server again.
- **A refused session never costs the payment, if you retry.** An unknown or expired id is a
  `400` with `param: "device_session"` (`resource_missing` or `device_session_expired`), and
  that refusal does not consume the `Idempotency-Key`. Send the same charge again with a fresh
  session or without one. If our fraud service cannot read a session, the charge goes ahead
  without it.
- **A renewal has no buyer at a browser**, so it has no session: charge it without one.
- **Confirming an intent from your server works the same way.** A payment intent or setup
  intent confirmed with a secret or restricted key takes `device_session` too, and without one
  your server's address is never scored as the buyer's. A confirm from the buyer's browser
  (the Payment Element) needs none.

`getDeviceSession()` resolves `null` on any failure. Charge anyway: without a session the charge
behaves exactly as before.

---

## Content-Security-Policy

If your checkout sets a CSP, the SDK needs these for fingerprinting and Kount's collector:

```
script-src  https://app.worthypayments.com
connect-src https://api.worthypayments.com
frame-src   https://*.kaptcha.com
```

`app.worthypayments.com` serves the fingerprint library (same host as the SDK; a page that
loads the SDK from `app.paygeyt.com` gets it from there, with `api.paygeyt.com`), and
`*.kaptcha.com` is for Kount's collector, which runs in a hidden iframe.

`js.evervault.com` (`script-src`) and `*.evervault.com` (`connect-src`, `frame-src`) are
needed on your page only if you show Apple Pay / Google Pay through the Evervault wallet
rail (`GET /v1/wallet-config` reports `provider: "EVERVAULT"`; the SDK then loads
Evervault on your page to mount the buttons) or run a 3-D Secure challenge yourself on
your page. The Payment Element encrypts the card and runs its challenge inside its own
frame, whose own policy already allows Evervault, so card entry and 3-D Secure through
it add nothing to your CSP.

Offering Apple Pay **and** `GET /v1/wallet-config` reports `applePay: true`? Add
`https://applepay.cdn-apple.com` to `script-src`. Safari blocks Apple's own script
without it while Google Pay keeps working, so it presents as an Apple-specific bug
rather than a policy problem. Skip this directive when wallets are off
(`provider: "NONE"` is the default).

> **Every one of these fails silently.** A blocked fingerprint or a blocked collector
> costs a fraud signal rather than the sale, which is right in production and confusing
> while integrating. When a device signal is unexpectedly empty, look for CSP violations
> in the console before anything else.

---

All three signals are optional on `POST /charges`. The Payment Element needs none of them from
you; build the charge yourself and they are yours to collect.

Related: [Payment Element](./08b-payment-element.md) · [Embedded SDK](./08-embedded-sdk.md) ·
[Charges](./03-charges.md) · [Fraud checks](./23-fraud-checks.md)
