Risk Decision Token

View as Markdown

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.


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/checkBound asEnforced at charge time
nothing to sendyour merchant accountAlways
nothing to sendthe verdict (allow / review / deny)Always
amount + currencythe exact amountWhen you sent an amount
card_bin + last4the card's public identityWhen you sent both
fingerprintthe deviceWhen 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.

CodeCauseFix
risk_decision_token_invalidBad signature, unknown format, or issued to a different accountDo not retry with this token. Call /v1/check again.
risk_decision_token_expiredOlder than 15 minutesCall /v1/check again.
risk_decision_token_amount_mismatchCharge amount or currency differs from the evaluated oneCharge the evaluated amount, or re-evaluate with /v1/check/recheck.
risk_decision_token_card_mismatchA different card than the one evaluatedRe-evaluate for the card you are charging.
risk_decision_token_device_mismatchA different device fingerprintSend the same fingerprint you sent to /v1/check.
risk_decision_token_reusedAlready spentOne token, one charge. Call /v1/check again.
card_declined (402, card_error)The token carries a deny verdictDo not retry. Show a generic decline.
service_degraded (503, api_error)Verification is temporarily unavailableRetry 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.)


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.