# Risk Decision Token

A signed receipt for one `POST /v1/check` evaluation. Echo it on
`POST /v1/charges` and the charge records which decision it followed.

```
POST /v1/check   →  risk_decision_token: "cmR0MXxtYV8x….9f8e7d…"
POST /v1/charges →  { …, "risk_decision_token": "cmR0MXxtYV8x….9f8e7d…" }
```

Optional to send. **Strictly enforced once sent.**

For the full list of integrator must-dos (IP, 3DS, idempotency, keys), see
[Integration hard rules](./27-integration-hard-rules.md).

---

## What it is not

It does not pre-approve a charge.

A charge carrying a valid token runs exactly the same fraud engine as a charge
carrying none: pre-screen, scoring, device intelligence, the 3DS step-up
decision, the block gate. The token can only ever cause a charge to be
**rejected**. It can never cause one to skip a check.

This is a deliberate design decision, not a limitation we plan to remove.
Minutes pass between a decision and a capture, and blocklists, velocity counters
and rules all change inside that window. A system that treats a decision as
authorization is one where an attacker who obtains one clean evaluation can spend
against it. So the token carries provenance, and the engine keeps the authority.

What you get from sending it:

- **A charge cannot silently drift from the decision it claims.** A different
  amount, card or device is rejected.
- **Provenance in the ledger.** The charge records the `check_id`, so a dispute
  can be traced back to the evaluation that preceded it.
- **A single-use guarantee.** One evaluation backs one charge.

---

## What it binds

The token is signed over the merchant, the verdict, and whichever of these you
supplied to `/v1/check`:

| Field on `/v1/check` | Bound as | Enforced at charge time |
|---|---|---|
| nothing to send | your merchant account | Always |
| nothing to send | the verdict (`allow` / `review` / `deny`) | Always |
| `amount` + `currency` | the exact amount | When you sent an amount |
| `card_bin` + `last4` | the card's public identity | When you sent both |
| `fingerprint` | the device | When you sent one |

**A field you omit is not bound, and the matching check is skipped.** That is
stated plainly rather than hidden: if you call `/v1/check` without an amount, the
token cannot stop an amount escalation, because the evaluation never saw an
amount to compare. Send `last4` alongside `card_bin` if you want the card
binding; send `fingerprint` if you want the device binding.

Valid for **15 minutes**. **Single use.**

---

## Errors

All are `400 invalid_request_error` with `param: "risk_decision_token"`, except
where noted.

| Code | Cause | Fix |
|---|---|---|
| `risk_decision_token_invalid` | Bad signature, unknown format, or issued to a different account | Do not retry with this token. Call `/v1/check` again. |
| `risk_decision_token_expired` | Older than 15 minutes | Call `/v1/check` again. |
| `risk_decision_token_amount_mismatch` | Charge amount or currency differs from the evaluated one | Charge the evaluated amount, or re-evaluate with `/v1/check/recheck`. |
| `risk_decision_token_card_mismatch` | A different card than the one evaluated | Re-evaluate for the card you are charging. |
| `risk_decision_token_device_mismatch` | A different device fingerprint | Send the same fingerprint you sent to `/v1/check`. |
| `risk_decision_token_reused` | Already spent | One token, one charge. Call `/v1/check` again. |
| `card_declined` (**402**, `card_error`) | The token carries a `deny` verdict | Do not retry. Show a generic decline. |
| `service_degraded` (**503**, `api_error`) | Verification is temporarily unavailable | **Retry without the token.** The charge runs the full fraud engine anyway. |

That last row is the one to build for. If token verification is degraded we
refuse rather than accept an unverified token, but the recovery is a plain
retry with the field omitted, and the payment goes through fully screened. An
infrastructure blip costs you provenance, never the sale.

---

## Amount changes between check and charge

Shipping added, a discount applied, tax recalculated: the token no longer
matches. Do not strip the token to make the error go away; re-evaluate:

```bash
curl -X POST https://api.worthypayments.com/v1/check/recheck \
  -H "X-Api-Key: sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "email": "buyer@example.com", "amount": 5499, "currency": "usd",
        "card_bin": "424242", "last4": "4242", "fingerprint": "fp_9a1c2b" }'
```

You get a fresh verdict at the real amount, and a fresh token.

---

## Deny tokens

A `deny` evaluation is signed too. It authorizes nothing, and presenting it on a
charge declines that charge. What it gives you is something to file when a customer
disputes a rejected order: proof of what we told you, and when.

It also means the field is present for **every verdict**, `deny` included, whenever token
signing is configured. A token that appeared only for `allow` would leak the verdict to
anyone who could see the response shape.

Two cases omit it, and neither is a verdict: token signing is not configured on the
platform, or the fraud engine was unreachable and the response is a fail-open default. The
second is identifiable by `check_id: ""` (see [Fraud
checks](./23-fraud-checks.md#post-v1check).)

---

## Not using it

Omit the field. Every charge runs the full fraud engine regardless, which is what
your integration does today and will keep doing. The token is for merchants who
want the decision and the charge provably tied together.
