# Worthy Payments API: Full Documentation
This single file contains the complete Worthy Payments developer documentation: every
conceptual guide followed by the full endpoint reference.
Base URL https://api.worthypayments.com/v1 (the same API also answers on
https://api.paygeyt.com/v1). Authenticate with the x-api-key header; keys are prefixed
sk_/pk_/rk_ and test_/live_, and the prefix alone selects test or live mode.
All money amounts are integers in the smallest currency unit, so 4999 is $49.99.
The browser integration is the Payment Element: your server creates a payment intent with
a secret key, and your page mounts our iframe against the returned client secret with a
publishable key. The card never enters the merchant's page, which is the single condition
for PCI SAQ A (~30 requirements) rather than SAQ A-EP (~139). The amount is never sent
from the browser.
Building a charge on your own server instead means tokenizing first: card.number and
card.cvv carry ev: tokens, or card.wp_token carries one wp:v1: envelope holding PAN,
expiry and CVV together (so there is no wp: cvv field). A raw PAN is rejected.
GENERATED FILE. Built from the guides listed in llms.txt by
apps/merchant-dashboard/scripts/build-llms-full.mjs. Edit the guides, not this file.
========================================================================
## GUIDES
========================================================================
# Quickstart
Go from zero to a successful charge.
The browser integration is the Payment Element. Your server names the price and creates
a payment intent; your page mounts our element against it. The card fields live in an
iframe served from our origin, so a PAN never enters your page. That is the condition
for PCI **SAQ A** (~30 requirements) rather than SAQ A-EP (~139).
If you would rather not run any card UI at all, skip to
[step 7](#7-alternative-hosted-checkout-link) for a hosted page you redirect to. If you
tokenize cards on your own server, see [step 8](#8-alternative-server-to-server-charge).
Amounts are integers in the smallest currency unit. `$49.99` is `4999`. A decimal is
rejected, not rounded.
Base URL for every call below:
```
https://api.worthypayments.com/v1
```
Every curl below sends a browser `User-Agent`. Cloudflare answers a request with an
**empty** UA as **1010** (HTML, not JSON). Curl's default UA is fine; empty is not.
Copy if you strip UA:
```
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
```
The Payment Element in a real browser already has a Chrome UA. See
[Authentication](/developers/02-authentication.md#cloudflare-user-agent) and
[Errors](/developers/10-errors.md#cloudflare-1010).
---
## 1. Create an API key
In the dashboard, go to **Developers → API keys**.
- A publishable key (`pk_test_…` / `pk_live_…`) belongs in your web page.
- A secret key (`sk_test_…` / `sk_live_…`) stays on your server. It creates intents and
checkout links, refunds, and reads data.
Start on test keys. Test-mode charges run the full engine (fraud, routing, 3-D Secure)
but never reach a live acquirer. Details in [Authentication](/developers/02-authentication.md).
**Test mode needs your own NMI sandbox key.** Test charges, saved cards and refunds run on
your own NMI sandbox account. Until its security key is in the dashboard (Settings >
Integrations, NMI card, with the dashboard in test mode), they answer
`403 sandbox_key_required`. No sandbox account yet: create one at
[guide.nmi.com](https://guide.nmi.com/). See
[Testing](/developers/15-testing.md#your-own-nmi-sandbox-key).
**Live keys need business verification first.** Until your KYB is approved, minting a
`pk_live_`/`sk_live_` is refused, and any live request made with one already issued
returns `403 account_not_activated`, including `POST /v1/payment_intents` and `POST
/v1/check`. This is not a scope problem and no amount of retrying clears it. Build against
test keys; see [Going live](/developers/21-going-live.md).
---
## 2. Allowlist your domains
When you create a publishable key in **Settings → Developers → API keys**, set
**Allowed domains** to the exact hostnames that will embed the element: production plus
whatever you develop on (`localhost`, staging, etc.). The create dialog requires at least
one domain for publishable keys.
Update the list on the key later without rolling the secret. A new checkout origin does
not need a new `pk_`. Settings → Developers → API keys → the globe on the key.
**Empty allowlist ≠ open element.** For the Payment Element, framing fails closed: no
domains configured means `frame-ancestors 'none'` and a blank iframe. That is the
opposite of the charge API's origin guard, which treats an empty list as "no origin
lock" for server-to-server calls. You must have the list set before the element will
render. Framing otherwise requires an **https** parent origin. Loopback HTTP is the
TLS exception for a local development server: `http://localhost`, `http://127.0.0.1`,
and `http://[::1]`. A downloaded HTML file (`file://`) will not frame. List
`localhost` on the key like any other domain. The secret key stays on your server.
The browser sends that origin as the `Origin` header. Do not put `http://localhost` in the
URL (`?origin=http://localhost:3000` on `/v1/element-frameable`). The public edge returns
HTML 403 and the API never sees the request. Local development is allowed; that query
shape is not. Prove the list with:
```bash
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
```
See [Errors, loopback in the URL](/developers/10-errors.md#loopback-in-the-url).
Once domains are set, a mismatched origin is refused (`domain_not_allowed` on charge
paths; a blank frame for the element). That blank frame is the most common first-run
failure, and what it tells you is worse than nothing:
- The browser console carries the real reason, from the browser rather than from us:
`Framing 'https://element.worthypayments.com/' violates the following Content Security Policy
directive: "frame-ancestors 'none'"`.
- `onError` fires with **`wallet_error: Invalid API key context`**. That names neither the
key nor the allowlist, and "wallet" sends you to Apple Pay and Google Pay, which have
nothing to do with it. Read it as *this publishable key cannot frame on this origin*:
the key is wrong for the account, or this origin is not on its allowed domains.
Check the origin against the key before you change anything else. `element-frameable`
above answers it in one call.
Wildcards match one label. `*.shop.com` covers `www.shop.com` but not `a.b.shop.com`,
so list each level you actually use. A wildcard does **not** cover the apex:
`*.shop.com` leaves `shop.com` refused, so list both if buyers check out at both.
---
## 3. Verify the key
```bash
curl https://api.worthypayments.com/v1/ping \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx"
```
> **Pinging the publishable key needs an `Origin`, and the error for forgetting it is
> misleading.** Step 1 hands you two keys, so the natural next move is to ping both. A
> `pk_` is a browser key and the server requires an origin from it:
>
> ```bash
> curl https://api.worthypayments.com/v1/ping \
> -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
> -H "x-api-key: pk_test_xxx" -H "Origin: https://your-site.example"
> ```
>Without it you get `403 domain_not_allowed — "Origin header required for browser keys"`.
>The code reads like *your domain is not on the list*, but **your allowlist was never
>consulted**; there was no origin to check it against. Do not go and edit your allowed
>domains; add the header. A `pk_` always reports exactly `["payments:charge"]` however you
>scoped it, so that is the correct answer, not a narrowed one. A different 403 is HTML
>from the public edge (`
403 Forbidden
`, no JSON): `?origin=http://localhost` in
>the URL. Local keys still work; send `Origin` and omit that query. See [Errors, loopback
>in the URL](/developers/10-errors.md#loopback-in-the-url).
> **On Windows.** Every example here is POSIX-quoted. In `cmd.exe` each `-H` needs its own
> closed pair of double quotes and a JSON body needs `\"` escaping:
>
> ```
> curl https://api.worthypayments.com/v1/ping -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" -H "x-api-key: sk_test_xxx"
> curl https://api.worthypayments.com/v1/payment_intents -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" -H "x-api-key: sk_test_xxx" -H "Idempotency-Key: order_1234" -H "Content-Type: application/json" -d "{\"amount\":4999,\"currency\":\"usd\"}"
> ```
>An unbalanced quote does not error, `cmd` folds the rest of the line into the previous
>header, so ` -H ` lands on the end of your API key and you get `401 api_key_invalid`,
>which reads as a bad key. PowerShell handles the double-quoted form above unchanged.
```json
{
"object": "ping",
"mode": "test",
"key_type": "secret",
"merchant_account_id": "cmt3yfavu00013iokngc9rjtw",
"api_version": "2026-01-01",
"scopes": ["payments:charge", "payments:read", "payment_intents:create"],
"unrestricted": false
}
```
`mode` is `test` or `live` from the key prefix; `key_type` is `secret` or `publishable`.
A 401 means the key is wrong.
`scopes` lists what this key can actually reach, as `resource:action` strings, and is the
fastest way to answer "why am I getting a 403". It is derived from the same rules the
guard enforces, not from the stored permission map, so it cannot disagree with the gate. A
publishable key always reports exactly `["payments:charge"]`. The server narrows it to
that regardless of what is stored, so the field discloses no privilege you did not already
know from the key type. `unrestricted: true` marks a legacy secret key with no permission
map at all, which still has full access; the dashboard labels it "Unrestricted (legacy
key)".
---
## 4. Create a payment intent on your server
The amount is set here with your secret key. Scope **`payment_intents:create`** is
required (server-only; a `pk_` is refused). Confirm later uses scope `none`: the
`client_secret` is the capability.
```bash
curl https://api.worthypayments.com/v1/payment_intents \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: order_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"description": "Order #1234",
"reference": "order_1234",
"receipt_email": "jane@example.com"
}'
```
| Field / header | Required | Notes |
|---|:---:|---|
| `amount` | Yes | Integer minor units (`4999` = $49.99). Decimals rejected. |
| `currency` | No | Defaults to `usd` |
| `description` | No | Line label on the intent |
| `reference` | No | Your order/cart id. It **is** echoed back on the money webhooks as `data.object.detail.orderReference`, which is the direct join. Storing it against the returned `id` + `client_secret` is still worth doing as a fallback. |
| `customer` | No | Existing customer id (not in the example) |
| `receipt_email` | No | Receipt destination. Send this **or** `billing.email` on confirm, with neither, the confirm fails `failed_precondition` / `customer email is required` |
| `Idempotency-Key` | Yes | Header, or body `idempotency_key`. Key it on the **order**, not the click. Same key → same intent. Omitting it is `400 idempotency_key_required`, checked before the body is validated, so a request missing both the key and the amount reports the key. See [Idempotency](/developers/12-idempotency.md). |
```json
{
"id": "pi_4b124c602f49458b92204957ee5b21b6",
"object": "payment_intent",
"amount": 4999,
"currency": "usd",
"status": "requires_payment_method",
"customer": null,
"receipt_email": "jane@example.com",
"latest_charge": null,
"client_secret": "pi_4b124c602f49458b92204957ee5b21b6_secret_rz2MzQr49QlZ3TW2UWq5cMI6xb9dd0wUMl7h",
"expires_at": "2026-01-01T12:30:00.000Z",
"created": "2026-01-01T12:00:00.000Z"
}
```
Hand **`client_secret`** to the browser (and nothing else sensitive). It appears on
create only; retrieve does not re-echo it.
---
## 5. Mount the Payment Element
```html
```
Pin `/sdk/v1/` in the script URL (unversioned always tracks latest and will change under
you). Defaults for `apiBase` / `elementOrigin` match production; override only for
staging. See [Embedded SDK](/developers/08-embedded-sdk.md).
There is no amount in this call. Confirm refuses `amount` / `currency`
(`parameter_unknown`). Card entry, wallets, 3-D Secure and saved cards run inside the
element. Optional mount fields: `appearance`, `locale`, `layout`, `fields`,
`defaultValues`, `customerSessionSecret`. See [Payment Element](/developers/08b-payment-element.md).
---
## 6. Handle the outcomes
`onSuccess` receives this named subset. Not the whole `GET /v1/charges/{id}` object:
```ts
{
paymentIntentId: string,
status: 'succeeded' | 'processing',
transactionId: string | null, // charge id; never null on 'succeeded'
charge?: {
id: string,
object: 'charge',
amount: 4999,
currency: 'usd',
status: 'succeeded',
paid: true,
captured: true,
auth_code: 'TESTOK',
processor_transaction_id: 'TEST-982e43e5', // gateway id (NMI transactionid)
card: { brand: 'visa', last4: '4242' },
billing_details: { name: 'Jane Doe', email: 'jane@example.com' },
// no processor, mid, network_transaction_id, avs_result, card.bin
},
card_save?: { status, payment_method, failure_code, failure_message }, // only when the buyer ticked "save this card"
}
```
`card_save` is explained in [Did the card get saved?](/developers/08b-payment-element.md#did-the-card-get-saved).
`charge` is present whenever it could be read back and omitted when it could not, enough
to render "Paid $49.99 with Visa ••4242" without a second call.
No token or vault id crosses out of the frame, and no PAN or CVV. Those are instrument
references; handing one to your page would put a reusable credential back on your side of
the boundary. `card.bin` is not there either, nor the merchant and acquirer facts the
server charge carries (`mid`, `processor` = Admin → Acquirers → **Code** of the acquirer
Payment Workflow chose for this sale, `arn`, `rrn`, settlement, AVS / CVV). Those stay
behind your secret key. `GET /v1/charges/{id}` omits a column when the row is empty.
`brand` and `last4` are not references: they are what the buyer typed and what their
receipt shows.
Use them to **display**, never to decide. Fulfilment reads the charge on your server or
the `charge.succeeded` webhook, because a value that reached your page through a browser
is a value a browser could have edited.
**`succeeded`** means the money moved. Fulfil the order.
**`processing`** means the outcome is not known yet. Do not fulfil. Wait for the
`charge.succeeded` webhook. The webhook body has `data.object.transactionId` (camelCase.
The payload is a protobuf message, unlike the REST fields elsewhere in this guide) and, if
you set one, your own `data.object.detail.orderReference`. It does not carry
`paymentIntentId`. If you set no reference, persist `id` + `client_secret` at step 4, then
`GET /v1/payment_intents/{client_secret}` and read `latest_charge`. Treating `processing`
as success is the one mistake that ships real money out the door.
**A decline does not throw and does not reach `onSuccess`.** It arrives on `onError`
with `code: 'card_declined'`, and the element stays mounted so the buyer can try another
card. Do not unmount it or navigate away. The element shows its own decline message; use
`onError` for your own analytics or surrounding UI.
**Webhooks (do this before production):** register an HTTPS endpoint for at least
`charge.succeeded` in **Settings → Developers → Webhooks**, then verify
`X-Webhook-Signature`. [Webhooks](/developers/16-webhooks.md) · test cards in [Testing](/developers/15-testing.md).
---
## 7. Alternative: hosted checkout link
No card UI on your side. Create a link server-side and redirect the buyer.
Scope: `checkout_links:create`. For `FIXED`, set `amount` (cents, typically ≥ 50).
```bash
curl https://api.worthypayments.com/v1/checkout-links \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Order #1234",
"amount": 4999,
"currency": "usd",
"link_type": "FIXED"
}'
```
```json
{
"id": "9f2c1b84-77ad-4e0c-9c31-0b6e5d2a4413",
"object": "checkout_link",
"url": "https://app.worthypayments.com/checkout/abc123",
"code": "abc123",
"active": true
}
```
Redirect to `url`. More in [Checkout Links](/developers/04-checkout-links.md).
---
## 8. Alternative: server-to-server charge
For an integrator who already tokenizes cards elsewhere and posts from a backend. This
path does not involve our SDK, and collecting the card yourself puts you in SAQ A-EP.
`POST /v1/charges` takes a tokenized card on one of two rails. Never a raw PAN.
- **`ev:` rail.** Per-field Evervault tokens: `card.number` and `card.cvv`.
- **`wp:` rail.** A single `card.wp_token`, a `wp:v1:` envelope carrying PAN, expiry and
CVV together. Because the envelope holds all three, there is no `wp:` CVV field, and
outer `exp_month` / `exp_year` are optional on this rail (expiry is already sealed).
```bash
curl https://api.worthypayments.com/v1/charges \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"card": {
"wp_token": "wp:v1:...",
"bin": "411111",
"last4": "1111",
"name": "Jane Doe"
},
"customer": { "email": "jane@example.com" }
}'
```
The full contract, the `ev:` variant, `requires_action` and decline handling are in
[Charges](/developers/03-charges.md) and [Card tokenization](/developers/28-card-tokenization.md).
---
## Next steps
- [Payment Element](/developers/08b-payment-element.md) for appearance, locale, layout and saved cards
- [Authentication](/developers/02-authentication.md) for key types and test vs live
- [Webhooks](/developers/16-webhooks.md) for settling `processing` and reconciling out of band
- [Errors](/developers/10-errors.md). The error envelope, the codes you hit while integrating, and
retries. ([Tokenization error codes](/developers/30-error-codes.md) is narrower: the seven `wp:`
rail codes only, which you will not see on the Payment Element path.)
- [Integration hard rules](/developers/27-integration-hard-rules.md) before you go live
- [Going live](/developers/21-going-live.md) for the switch to `pk_live_` / `sk_live_`
========================================================================
# Payment Element
The Payment Element is a payment form we serve in an iframe and you drop into your page.
It collects the card, runs 3-D Secure **when enabled** for the account, shows saved cards,
and calls you back when the money is captured. Apple Pay and Google Pay mount on the
**merchant page, directly above the card iframe**. Not inside it. They are off until `GET
/v1/wallet-config` reports them on. See [Wallets and 3-D Secure](#wallets-and-3-d-secure),
which is also where the CSP entries they need are listed.
Because the fields live in our iframe and your scripts cannot read them, your page
stays on the PCI **SAQ A** path (~30 requirements) rather than SAQ A-EP (~139). That
is the reason the element exists.
```html
```
Load the pinned `/sdk/v1/` bundle. The unversioned `/sdk/worthypayments.umd.js`
always serves the newest build, which turns every deploy of ours into an untested
change to your live checkout.
---
## What the element draws, and what stays yours
The element renders a complete payment form and nothing else:
| Inside the iframe (ours) | On your page (yours) |
|---|---|
| Card number, expiry, CVC, and the network's mark once the number identifies one | The amount the buyer is paying |
| Name on card | Order summary, line items, shipping |
| Billing email, full name, address, city, postal, state, country | Your header, branding and layout |
| The **Pay** button | Terms, trust marks, support links |
| 3-D Secure and saved cards | The success and failure screens |
**Apple Pay and Google Pay are the exception: they mount on YOUR page, not in the
frame.** The SDK inserts them itself, in a block directly above the card form, because a
wallet sheet has to be opened by the page the buyer is actually on — that is the origin
Apple Pay checks against your registered domains. You do not wire them up (they appear whenever the key has
wallets enabled, unless you pass `wallets: 'never'`), but the block is in your document,
so its width and the space around it follow your layout.
⚠️ **The total is not in the form.** The amount lives on the intent, and the element
never displays it, so a buyer who sees only the iframe is pressing **Pay** without a
figure in front of them. Render the total yourself, next to the element. This is the
same rule from the other side: the browser cannot *name* the price, and it does not
*show* it either.
The `appearance` option restyles what is inside the frame so it does not look pasted onto
your page. It cannot move fields or add copy. See [Styling](#styling).
While the frame loads, the element holds **empty space** the size of the form that is
coming — it does not draw a stand-in form. Reserving the height keeps your page from
jumping when the real form arrives; drawing a fake one would make that arrival look like
the content changing under the buyer.
## Before anything renders: allowlist your domain
The element refuses to render for an origin that is not on your key's allowed domains,
and a refusal looks like a **blank frame** rather than an error. It is the most common
first-run failure.
Set the domains on your publishable key when you create it in **Settings → Developers →
API keys** (**Allowed domains** field; required for publishable keys). List the exact
hostnames that will embed the element. Update the list on the key later without rolling
the secret. A new checkout origin does not need a new `pk_`. The parent page must be
served over **https**. Loopback HTTP is the TLS exception for a local development server:
`http://localhost`, `http://127.0.0.1`, and `http://[::1]`. Opening a downloaded HTML file
(`file://`) is not an origin we frame. List the hostname (`localhost`) on the key like any
other domain. The secret key stays on your server; only `pk_` belongs in the page.
**Checkout framed by another of your sites.** The browser checks every page above the
card form, not only the one that mounts it. If `shop.example.com` mounts the element and
is itself framed by `dash.example.com`, list **both** on the key. Chrome, Edge and Safari
report the whole chain; Firefox reports only the page directly above, so on Firefox one
level of framing is supported.
Local checkout with that key is allowed. The page identifies itself with the `Origin`
header; the SDK already sends it. Do **not** put `http://localhost` in a public URL query
(`GET /v1/element-frameable?origin=http://localhost:3000`, or `?parent=http://localhost…`
on the element host). The public edge answers HTML 403 before the API runs. That is not a
ban on local development. Probe with the header and no query:
```bash
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
```
`200 {"allowed":true}` means the key will frame that origin. See [Embedded SDK (local
development](/developers/08-embedded-sdk.md#local-development) and [Errors) loopback in the
URL](/developers/10-errors.md#loopback-in-the-url).
Wildcards match a single label, so `*.shop.com` covers `www.shop.com` but not
`a.b.shop.com`, and it does **not** cover the apex: `*.shop.com` leaves `shop.com`
refused, so list both if buyers check out at both. The same list decides which origins
may charge with the key, which is why it is not a styling detail.
**Important:** An empty allowlist does not open the element. Framing fails closed: no domains
configured → blank iframe. That is the opposite of the charge API's origin guard, which
treats an empty list as "no origin lock" for S2S calls. Set the list before you embed.
---
## The flow has two steps
```
1. YOUR SERVER POST /v1/payment_intents (sk_) -> client_secret
2. BROWSER wp.mountPaymentElement('#payment', { clientSecret, onSuccess })
```
Your server names the price. The browser gets a secret that addresses that decision,
not the decision itself, so a buyer editing your page cannot change what they pay.
If your checkout only creates the order when the buyer pays, swap the two steps: mount
first with `mode: 'payment'`, and create the intent in `createIntent` at submit. See
[Mount before the order exists](#mount-before-the-order-exists).
The same mount collects the **first invoice of a subscription**. `POST /v1/subscriptions`
with `payment_behavior=default_incomplete` (or without a saved card) returns `status:
"incomplete"` and `latest_invoice.payment_intent.client_secret`, pass that secret here. Do
not put `plan` or `interval` on the Element; the server already named the plan. See
[Subscriptions](/developers/18b-subscriptions.md).
---
## 1. Create the intent (your server)
Requires secret key + scope **`payment_intents:create`** (server-only).
`Idempotency-Key` is required. The browser later confirms with scope `none`: the
returned `client_secret` is the capability, not a second write scope.
```bash
curl https://api.worthypayments.com/v1/payment_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: ord_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"description": "Order #1234",
"reference": "ord_1234",
"receipt_email": "buyer@example.com"
}'
```
| Field / header | Required | Notes |
|---|:---:|---|
| `amount` | Yes | Minor units integer (`4999` = $49.99). Decimals rejected. |
| `currency` | No | Defaults to `usd` |
| `description` | No | Line label |
| `reference` | No | Your order id (echoed later) |
| `metadata` | No | Your own key/value pairs, same rules as on `POST /v1/charges` (50 keys, keys up to 40 characters without `[`/`]`, string values up to 500). Answered to your server only, and copied onto the charge the intent makes. See [Metadata and the other details](#metadata-and-the-other-details) |
| `customer` | No | An existing customer of this key's merchant and mode: the `cus_…` id `GET /v1/customers` returns, the bare uuid, or the `CUS-…` number. The intent answers with the `cus_…` form. Any other value is a 400 `resource_missing` with `param: customer` (`No such customer: '…'`), and the same key replays it |
| `receipt_email` | No | Receipt destination. Send this **or** `billing.email` on confirm, with neither, the confirm fails `failed_precondition` / `customer email is required` |
| `three_ds` | No | `{ "request": "any" \| "challenge" \| "challenge_mandated" }` runs 3-D Secure on the card the buyer enters, whatever your rules decide (EMV indicators 01 / 03 / 04); default `automatic` leaves it to your rules, which still win when they ask. The Element runs the challenge, on a saved card too; only a saved card with no encrypted copy (saved before 2026-10-18, or imported) is re-entered first ([3-D Secure on a saved card](#3-d-secure-on-a-saved-card)); a wallet or a card sealed on the `wp:` rail pays without it. Read back on every intent as `three_ds.request`. See [3-D Secure](/developers/07-3ds.md#asking-for-3ds) |
| `Idempotency-Key` | Yes | Header or body `idempotency_key` (stable per order) |
```json
{
"id": "pi_4b124c602f49458b92204957ee5b21b6",
"object": "payment_intent",
"amount": 4999,
"currency": "usd",
"status": "requires_payment_method",
"client_secret": "pi_4b124c602f49458b92204957ee5b21b6_secret_rz2MzQr49QlZ3TW2UWq5cMI6xb9dd0wUMl7h"
}
```
Send only `client_secret` to the page. It appears on create only. Base URL:
`https://api.worthypayments.com/v1`.
---
## 2. Mount the element (browser)
Copy this HTML, replace the two placeholders, and serve it from an origin on the
publishable key's allowed domains. Pin the `/sdk/v1/` bundle. `onSuccess` is required.
```html
Checkout
```
Card entry, field validation, saved cards and retries happen inside the iframe. Apple Pay
and Google Pay mount on the merchant page, directly above that iframe. 3-D Secure is
driven by the element; a challenge opens inside the iframe, which grows to fit it, and your
page is covered while it runs ([The 3-D Secure overlay](#the-3-d-secure-overlay)).
| Option | Required | Default | When you need it |
|---|:---:|---|---|
| `clientSecret` | Yes, unless `mode` | — | From `POST /payment_intents` |
| `mode`, `amount`, `currency`, `createIntent` | Instead of `clientSecret` | — | Mount before the intent exists; your server creates it when the buyer pays. See [Mount before the order exists](#mount-before-the-order-exists) |
| `onSuccess` | Yes | — | Fulfil UI only; money confirmation still via webhook |
| `onError` | No | silent | Declines / faults (`card_declined`, …). Element stays mounted. |
| `onLoadError` | No | silent | The form could not load at all (`element_load_failed`, after 10 s): usually the key's allowed domains or your CSP. Not a decline: fall back to a checkout link, never "card declined". |
| `customerSessionSecret` | No | — | Returning buyer saved cards (from `POST /customer_sessions`) |
| `fields.billingDetails` | No | `auto` | `'never'` or `{ email, name, address }` each `'auto'\|'never'` |
| `defaultValues.billingDetails` | No | — | Prefill, and values for hidden blocks (keeps AVS) |
| `fields.savePaymentMethod` | No | `never` | `'auto'` draws an untickable-by-default "save this card" box, except on an intent created with `setup_future_usage`, which saves the card anyway. See [Saving a card](#saving-a-card-for-later) |
| `appearance` | No | — | Theme variables inside the frame |
| `payButton` | No | — | `{ label: 'Start membership' }` for your own wording, or `{ display: 'never' }` to draw no button and call `element.submit()` from yours |
| `locale` | No | `en` | `es`, `es-MX`, `es-ES`, … |
| `onReady` / `onChange` / `onCancel` | No | — | Load state, field completeness, wallet sheet dismiss |
| `onEscape` | No | — | The buyer pressed Escape inside the frame; close your own modal. Not called while 3-D Secure runs |
| `wallets` | No | `auto` | `'never'` when your page already mounts Apple Pay / Google Pay |
| `walletButtons` | No | black, 48 px | `{ applePay: { theme, type }, googlePay: { theme, type }, height }` — the providers' own button presets. See [Embedded SDK](/developers/08-embedded-sdk.md#wallet-buttons) |
| `recurring` | No | — | This payment starts a subscription: the Apple Pay sheet shows its terms, and a wallet payment asks to save its method. See [Subscriptions paid by wallet](#subscriptions-paid-by-wallet) |
Handle methods: `element.update({ clientSecret?, appearance?, locale?, defaultValues? })`
(never an amount on a `clientSecret` mount; `fields` is mount-only), `element.submit()`,
`element.destroy()`.
**When the cart changes, your server writes the new total to the intent** with [`PATCH
/v1/payment_intents/:id`](#when-the-cart-changes). The client secret does not change, so
the mounted element needs nothing from you.
Full types: [Embedded SDK](/developers/08-embedded-sdk.md).
### Three ready-made forms
The one option that changes what the buyer sees is `fields.billingDetails`. These three
settings cover most checkouts. Each has a runnable page: open it, paste your own test
keys, pay with a test card, and read what came back on the charge.
| Form | The frame draws | Mount with | Try it |
|---|---|---|---|
| **1 · Card only** | Card number, expiry, CVC, name on card | `fields: { billingDetails: 'never' }` **and** `defaultValues.billingDetails` from your page | [/demos/element-card-only.html](/demos/element-card-only.html) |
| **2 · Full billing** | Card + email, name, street, city, postal code, state where the country has one, country from a list of all 249 regions | nothing: `'auto'` is the default | [/demos/element-full-billing.html](/demos/element-full-billing.html) |
| **3 · Smart address** | Card + email, name, and only the address lines the buyer's country uses | `fields: { billingDetails: { address: 'if_required' } }` | [/demos/element-smart-address.html](/demos/element-smart-address.html) |
**Card only** is for a page that already holds the buyer: an account checkout, a
subscription upgrade, a second purchase. The buyer sees no billing field, and the charge
still carries the address, because you pass it:
```js
wp.mountPaymentElement('#payment', {
clientSecret,
fields: { billingDetails: 'never' },
defaultValues: {
billingDetails: {
email: 'ada@example.com',
name: 'Ada Lovelace',
address: { line1: '1 Main St', city: 'Austin', state: 'TX',
postal_code: '78701', country: 'US' },
},
},
onSuccess: (r) => { /* r.charge.billing_details carries that address */ },
});
```
`onSuccess` hands the address back under `charge.billing_details`, which is how the demo
page shows that AVS had its input. Two rules come with this form. `email` is not optional
unless the intent carries `receipt_email`: a confirm with neither fails `customer email is
required`. And hiding the block **without** `defaultValues` is the case the warning below
this section is about: no address reaches the issuer. If the buyer changes after the
element is mounted, `element.update({ defaultValues })` takes the new values. You can
call it right before `element.submit()`, in the same tick, to hand over the billing form
on your own page at the moment of payment: the submit confirms the values the update
carried. `address.line2` rides on the charge with `line1`; the element draws no field for
it, and drops it when the buyer types a different street.
**Full billing** is the default and needs no option. The element reports `complete: true`
only once every drawn field is filled, and the country is a list, not a text box, so a
typo cannot reach the acquirer as an AVS country.
**Smart address** keeps street and postal code, which AVS checks, and drops the state or
province line in the countries whose addresses have none. Pick the United States, then
the United Kingdom, and watch the line go. It asks nothing of your page, unlike `'never'`.
`phone` is off in all three, because no card rail needs it. `{ phone: 'auto' }` inside
the object form draws it.
### What comes back
The iframe confirms with `POST /v1/payment_intents/:client_secret/confirm` (publishable
key). That HTTP body is a `payment_intent` (`id`, `object`, `status`, `latest_charge`,
`amount`, `currency`, `customer`, … plus a receipt `charge`). The SDK maps it to
`FrameSuccess` before `onSuccess`. The parent page does **not** receive the extra intent
fields.
```ts
interface FrameSuccess {
paymentIntentId: string;
status: 'succeeded' | 'processing';
transactionId: string | null;
charge?: FrameCharge; // named subset of GET /v1/charges/{id}, not the whole object
card_save?: { // only when the buyer ticked "save this card"; see below
status: 'pending' | 'saved' | 'skipped' | 'failed';
payment_method: string | null; // 'pm_...' when saved
failure_code: string | null;
failure_message: string | null;
};
}
```
```json
{
"paymentIntentId": "pi_79bb4da94cac40d383ee3d0403b5f8be",
"status": "succeeded",
"transactionId": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
"charge": {
"id": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
"object": "charge",
"amount": 1999,
"currency": "usd",
"status": "succeeded",
"paid": true,
"captured": true,
"amount_captured": 1999,
"amount_refunded": 0,
"auth_code": "TESTOK",
"processor_transaction_id": "TEST-2a7af918-7888-4d74-87c0-45c800ea7477",
"payment_method_type": "card",
"metadata": {},
"created": 1788217437,
"card": { "brand": "visa", "last4": "4242" }
}
}
```
`charge` is present whenever it could be read back and omitted when it could not, enough
to render "Paid $49.99 with Visa ••4242" without a second round-trip.
`processor_transaction_id` is the gateway's own id (NMI `transactionid`).
It is a named subset. The merchant and acquirer facts on the server charge (`mid`,
`processor`, `processor_label`, `network_transaction_id`, `arn`, `rrn`, settlement,
and the AVS / CVV verdicts) stay behind your secret key: they answer a different
question than what the buyer just paid, and this payload lands in a page the buyer
can read. Read them on `GET /v1/charges/{id}` (`payments:read`) or the
`charge.succeeded` webhook. Empty columns are omitted, not sent as `""`.
`processor` is Admin → Acquirers → **Code** of the acquirer **Payment Workflow** chose for
this sale (`NMI`, `PAYARC`, `NUVEI`, `MAVERICK`, … as configured. Not a fixed string). All
of those processors share the merchant's one NMI security key; the NMI `processor_id` on
the sale is that row's **Connector code (`processor_id`)** / charge code. **Connector
codes** map raw gateway keys onto the same row. Neither is the public `processor` field.
See [Charges](/developers/03-charges.md#which-acquirer-ran-it-and-what-the-gateway-said).
No instrument reference crosses the boundary: no PAN, no CVV, no token, no vault id, and
no `card.bin`. `card_save.payment_method` is not one either: it is a WorthyPayments `pm_…`
id, chargeable only with your secret key or that buyer's own customer session. `brand` and
`last4` are what the buyer typed and what their receipt shows.
Use them to display, not to decide, fulfilment still reads the charge on your server or
the `charge.succeeded` webhook.
Keep `fields.billingDetails` at `'auto'` unless you already collect that block on
your page. Then hide it (`'never'` or `{ address: 'never' }`) **and** pass the
known values in `defaultValues.billingDetails` so they still ride on the charge.
> **Hiding the address without `defaultValues` sends the charge with no AVS, and that is
> not a cosmetic loss.** AVS is the issuer's check of the billing address against the one
> on file. Submitting nothing means the issuer scores the authorization without it, which
> shows up as a **lower approval rate** and, in a dispute, as a **weaker position**. An
> unverified address is one of the first things a chargeback file is judged on. The
> element cannot cover for you here. `'never'` says "I collect this myself", and the frame
> has no way to see what your page collected, so it cannot tell a merchant who passed the
> address in `defaultValues` from one who dropped it. The only safe reading of `'never'`
> without `defaultValues` is **"I accept charging without AVS"**. If you already have the
> buyer's address, hide the block *and* pass it:
> ```js
> fields: { billingDetails: { address: 'never' } },
> defaultValues: {
> billingDetails: {
> address: { line1: '1 Main St', city: 'Austin', state: 'TX',
> postal_code: '78701', country: 'US' },
> },
> },
> ```
---
## 3. Handle the three outcomes
**`onSuccess` with `status: 'succeeded'`.** The money is captured. You can show a
confirmation, though fulfilment should still key off the webhook.
**`onSuccess` with `status: 'processing'`.** The outcome is not known yet: the card
network did not tell us in time whether the card was charged. Do not fulfil. Wait for
`charge.succeeded`, or retrieve the intent (`GET /v1/payment_intents/{client_secret}`)
every few seconds. A retrieve asks for the outcome and never charges; the intent moves
to `succeeded`, or to `requires_payment_method` with
`last_payment_error.decline_code: 'payment_not_completed'` when nothing was charged and
the buyer can pay again. While the buyer is still in a 3-D Secure challenge it reads
`requires_action`, never "not charged". Never confirm again to find out: a confirm is a payment
attempt, not a status read. `processing` is also the fallback for any state we do not
recognise, on purpose: reporting `succeeded` would tell you to ship goods that may not
be paid for, and reporting a decline would tell a buyer whose card *was* charged that
it was not.
A confirm that is still running after 55 seconds (or after the longer `timeout` you
configured) is settled the same way: the SDK stops
waiting, retrieves the intent (a read that never charges) and reports `succeeded` or
`processing` from it. Only when that read fails too does `onError` receive
`code: 'request_timeout'`, and the outcome is then unknown: check the intent or wait for
the webhook before letting the buyer pay again.
**`onError` with `code: 'card_declined'`.** A decline does not throw and does not
arrive on `onSuccess`. The element stays mounted and the intent stays alive, so the
buyer can try another card without you re-creating anything. `status` on `onSuccess`
is never `'requires_payment_method'`. The error's `type` is `'card_error'`, for the card
form and for Apple Pay / Google Pay alike.
The error carries `declineCode` when the decline has one (`insufficient_funds`,
`do_not_honor`, `duplicate_transaction`, `processing_error`, …): the same value as the
intent's `last_payment_error.decline_code`. `message` is the wording the element already
showed the buyer, in the element's `locale` (English and Spanish):
| The decline | What the buyer reads |
|---|---|
| `duplicate_transaction` (the gateway refused it as a repeat of a payment just made) | "This looks like a duplicate of a payment you just made." |
| `insufficient_funds` | "Your card was declined for insufficient funds. Try another card." |
| `incorrect_cvc` / `cvv_mismatch` | "Your card's security code is incorrect." |
| `incorrect_zip` | "Your card's postal code is incorrect." |
| `incorrect_address` | "Your card's billing address is incorrect." |
| `expired_card` | "Your card has expired. Try another card." |
| `incorrect_number` / `invalid_number` | "Your card number is incorrect." |
| `amount_too_large` / `amount_too_small` | "This business can't accept a payment of this amount. You have not been charged." |
| `currency_not_supported` | "This business can't accept payments in this currency. You have not been charged." |
| `merchant_unavailable` | "This business can't accept payments right now. You have not been charged." |
| `test_mode_live_card` | "Your card was declined. This payment is in test mode: use a test card." |
| `soft_decline_3ds` on a saved card (3-D Secure is needed; the card form opens, see [3-D Secure on a saved card](#3-d-secure-on-a-saved-card)) | "Your bank needs to verify this card. Enter the card details again to continue. You have not been charged." |
| `three_ds_failed` (the 3-D Secure session did not authenticate) | "Your bank could not verify this card. Try again or use another card." |
| `payment_not_completed`, `try_again_later`, `issuer_unavailable`, and any other refusal by the gateway before the card network saw it (`last_payment_error.network_status: 'not_sent_to_network'`) | "The payment could not be processed. Please try again." |
| everything else, an issuer `processing_error` and the fraud family (`fraudulent`, `lost_card`, `stolen_card`, `pickup_card`) included | "Your card was declined. Try another card." |
The postal code, address, amount and currency lines come from your own
[Payment Workflow rules](/developers/10-errors.md#payment-workflow-rules): the payment was refused
before it reached the gateway, and nothing was charged.
```js
onError: (e) => {
if (e.declineCode === 'duplicate_transaction') {
// the buyer may already have paid: check your orders before letting them pay again
}
},
```
Do not let a buyer retry a `duplicate_transaction` blindly: look the payment up
(`GET /v1/charges`, or your `charge.succeeded` webhook) first. Read a gateway refusal from
`last_payment_error.network_status`, never from the code: `processing_error` is also an
issuer answer. See [Gateway refusals](/developers/10-errors.md#gateway-refusals).
### Fulfil on the webhook, not in the browser
| Event | Meaning |
|---|---|
| `charge.succeeded` | Money captured. Fulfil now. |
| `charge.failed` | It will not settle. |
Match an event to your order with `data.object.detail.orderReference`: it is the
`reference` you set when your server created the intent, echoed back. Set it. A decline
carries it too, while the browser never receives a `transactionId` for a decline, so the
charge id alone cannot tie a `charge.failed` to an order. The intent's `metadata` rides along
too, as `data.object.detail.metadata`. Without a `reference`, use the
payment-intent fallback in [Webhooks](/developers/16-webhooks.md#matching-a-delivery-back-to-your-order).
Configure the endpoint in **Settings -> Developers -> Webhooks** and verify the signature.
See [Events](/developers/19-events.md).
Do not fulfil from a browser poll. The buyer may close the tab the moment they pay, and a
charge that settles two seconds later would then never be fulfilled. A page may retrieve
a `processing` intent to show the buyer its outcome, but fulfilment keys off the webhook,
which arrives regardless of what the page is doing.
---
## When the cart changes
The buyer adds an item, picks a different shipping option, applies your own discount. Write the
new total to the intent from your server:
```bash
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_4b124c602f49458b92204957ee5b21b6 \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"amount": 7500, "description": "Order #1234"}'
```
Note the two things this route does differently from every other one on this page:
- **It takes the `pi_...` id, not the client secret.** The secret is in the buyer's page.
If a re-price were addressed by it, a buyer could re-price their own order, which is
precisely the decision the element exists to keep on your server. Reaching this route
needs the server-only scope `payment_intents:create`, so a `pk_` cannot call it at all.
- **The client secret does not change.** There is nothing to hand the browser and no
`element.update()` to call; a mounted element keeps working and the buyer pays the new amount.
Apple Pay and Google Pay buttons drawn before the change are covered too. A wallet sheet
shows the total it was drawn with, so a wallet payment whose sheet still showed the old
total is refused before any charge: `onError` receives `code: 'amount_changed'`
(`type: 'invalid_request_error'`), and the buttons redraw with the new total for the buyer
to tap again.
Only an unpaid intent moves. Once the buyer has submitted and a charge is in flight, the
call fails with `400 failed_precondition` rather than quietly doing nothing, so a late cart
edit can never disagree with what the card was actually charged. An expired or paid intent
answers the same.
Retrying is safe. The body names the amount the intent should **end up** at, not a delta, so
sending it twice lands on the same total. That is why no `Idempotency-Key` is required here,
unlike create.
> **Do not create a second intent for a changed cart.** It still works, but it leaves the first
> one to expire unpaid, and the browser then has to be handed a fresh `clientSecret` through
> `element.update({ clientSecret })`. Patching is one call and the page never learns the price.
### Metadata and the other details
`metadata` is your own key/value pairs on the intent: an order id, a cart id, whatever ties the
payment to your system. Set it on create, change it with the same `PATCH`, and change
`reference`, `customer` and `receipt_email` there too. Send only what changes:
```bash
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_4b124c602f49458b92204957ee5b21b6 \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"reference": "ord_1235", "metadata": {"order_id": "8812", "coupon": ""}}'
```
- **`metadata` is merged, as on Stripe.** A key you send is set, a key sent as `""` is
removed (`coupon` above), and keys you do not send stay. To empty it, send each key with
`""`; `metadata: null` is refused. The intent holds at most 50 keys.
- **`reference` and `receipt_email`** are replaced; `""` or `null` clears them.
- **`customer`** is checked like on create and replaces the intent's customer. It cannot be
removed, and it **cannot change once a payment attempt on the intent started**, a declined
one included (`400 failed_precondition`, `param: customer`): that attempt, and a card it
saved, belong to the customer the intent had then. A card already saved never moves.
- **The same states as the amount:** an intent that is paid, being paid or expired refuses
every change with `400 failed_precondition`.
- **Who sees it.** `metadata` comes back with `reference` on create, on `PATCH`, and on
retrieve with a secret or restricted key. The publishable key in the buyer's page never
receives either, so you can keep the buyer's details or your internal ids there.
- **The charge copies it.** When the buyer pays, the charge takes the intent's `metadata`
once, as it is at that moment: `GET /v1/charges/{id}` carries it as `metadata`, the
`charge.*` webhooks as `detail.metadata`, and `checkout.session.completed` as `metadata`.
Editing the intent after a declined attempt reaches the next attempt's charge, never the
declined one.
---
## Mount before the order exists
Some checkouts only know the order when the buyer presses "Place order" (WooCommerce, a cart
that is priced on submit). Mount the element first, without a client secret, and create the
intent on your server when the buyer pays. This is Stripe's
`elements({ mode: 'payment', amount, currency })`.
```js
const element = wp.mountPaymentElement('#payment', {
mode: 'payment',
amount: 1099, // the total, smallest currency unit
currency: 'usd',
// Runs when the buyer pays: create the intent with your secret key, return its secret.
createIntent: async () => {
const res = await fetch('/checkout/create-intent', { method: 'POST' });
if (!res.ok) throw new Error('Could not create the order');
return (await res.json()).client_secret;
},
onSuccess: (r) => { /* as above */ },
onError: (e) => { /* declines, and intent_creation_failed when createIntent threw */ },
});
// The cart changed before the buyer paid:
element.update({ amount: 2099 });
```
1. The form is complete and the buyer presses Pay (or you call `element.submit()`, or a
wallet sheet is approved).
2. The element calls `createIntent`. Your server runs `POST /v1/payment_intents` with the
order's real total and returns the `client_secret`.
3. The element confirms that intent (3-D Secure included) and calls `onSuccess` or
`onError`, exactly as a `clientSecret` mount does.
What to know:
- **Your server still names the price.** `amount` is what the Apple Pay / Google Pay sheet
shows; the charge is the amount on the intent you create. Create it with the same total.
A wallet payment whose intent disagrees with the sheet is refused before any charge
(`amount_changed`).
- **An incomplete form never calls `createIntent`**, so no unpaid intent is left behind.
Each attempt calls it again; your server may hand back the same unpaid intent's secret
for a retry after a decline.
- **If `createIntent` throws,** nothing is charged: `onError` receives
`intent_creation_failed` with your error's message, and the element tells the buyer the
payment could not be started and lets them try again.
- When the cart changes, call `element.update({ amount })` so the wallet buttons redraw.
There is no intent to PATCH yet. `update({ clientSecret })` is refused on this mount.
Full reference: [Embedded SDK](/developers/08-embedded-sdk.md#mounting-before-the-intent-exists).
---
## Saving a card for later
A saved card is the other half of the section below: something has to enrol the card
before there is anything to show a returning buyer, and before a subscription has a
stored method to bill (see [Customers & saved cards](/developers/18-customers.md)).
Turn the offer on at mount:
```js
wp.mountPaymentElement('#payment', {
clientSecret,
fields: { savePaymentMethod: 'auto' }, // draws the checkbox, UNTICKED
onSuccess: (r) => { /* .. */ },
});
```
The element then draws a **"Save this card for future payments"** checkbox under the
card fields. The buyer ticking it is the consent, and the card is enrolled only on that
tick. Default is `'never'`: no box, nothing saved.
Three things about this that are deliberate and not configurable:
- **The box is never pre-ticked, and there is no option to pre-tick it.** A pre-ticked
box is not valid consent under GDPR, and the same reasoning holds for card-network
stored-credential rules.
- **It only appears for a fresh card.** A buyer paying with a card they already saved
sees no box, because there is nothing to enrol.
- **Nothing is saved without it.** Absent consent the card is stored nowhere: no payment
method on your account, and no vault entry at the gateway either.
You are responsible for the terms that go with this. Before you enable it, state on your
own page what you will use the saved card for, when you will charge it, and how the buyer
cancels, and keep a record of their agreement. The checkbox collects consent for
*storage*; it is not a substitute for your terms.
Attach the payment to a customer so the card has somewhere to live: pass `customer` on
[`POST /v1/payment_intents`](#1-create-the-intent-your-server). It must be an existing
customer of the same merchant and mode as your key, or the create answers 400
`resource_missing`. That customer is the payment's and the card's owner, whatever email
the buyer types in the Element; the email is only where the receipt goes. Without
`customer`, the buyer's email decides: the payment and the card attach to the customer
with that email, created if none exists.
### Always saving the card (subscriptions)
When the card must be saved whatever the buyer does, for example the first payment of a
subscription you renew yourself, create the intent with `setup_future_usage` and a
`customer`:
```bash
curl https://api.worthypayments.com/v1/payment_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: sub_42_first" \
-H "Content-Type: application/json" \
-d '{ "amount": 2900, "currency": "usd",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"setup_future_usage": "off_session" }'
```
- The card is saved to that customer once the payment is approved. The element draws no
"save this card" box for this intent, even with `fields.savePaymentMethod: 'auto'`, and
the buyer's page cannot turn the save off.
- With a deferred mount (`mode: 'payment'`, the intent created in `createIntent`), pass
`setupFutureUsage: 'off_session'` (or `'on_session'`) to `mountPaymentElement` as well, so
the element draws no box before the intent exists, and create each intent with the same
`setup_future_usage`. The intent is what saves the card; the option only shapes the form.
On a `clientSecret` mount the option is refused (`setup_future_usage_with_client_secret`).
- `off_session`: you will charge the card without the buyer present (renewals).
`on_session`: the buyer will pay with it again at your checkout. Both save the card the
same way.
- This payment is the card's initial stored-credential transaction. Later
merchant-initiated charges on the saved card (`recurring: true`, `initiated_by:
"merchant"` on [`POST /v1/charges`](/developers/03-charges.md)) refer to it.
- `customer` is required: without it the create is a 400 `parameter_missing` with
`param: customer`.
- The result is `card_save`, read as below. A wallet payment (Apple Pay, tokenised Google
Pay) cannot be saved: it succeeds and answers `skipped` / `card_not_saveable`. A saved
card the buyer picks is already on file and carries no `card_save`.
- The box was the buyer's consent; without it, consent is your checkout's job. State on
your page that the card is kept for later payments and how they are charged.
### Did the card get saved?
When the buyer ticked the box and the payment succeeded, `onSuccess` receives
`card_save`, copied from the confirm response (the same object
`GET /v1/payment_intents/{client_secret}` returns for that intent):
```js
onSuccess: (r) => {
if (r.card_save?.status === 'saved') {
showSavedNotice(r.card_save.payment_method); // 'pm_...'
}
},
```
```json
{
"paymentIntentId": "pi_79bb4da94cac40d383ee3d0403b5f8be",
"status": "succeeded",
"transactionId": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
"card_save": {
"status": "saved",
"payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
"failure_code": null,
"failure_message": null
}
}
```
| `card_save.status` | What it means |
|---|---|
| `saved` | The card is saved for the customer (newly, renewed, or it already was). `payment_method` is its `pm_…` id. |
| `pending` | The save may still be running, or its outcome is unknown. **It can stay pending.** |
| `skipped` | Not attempted. `failure_code`: `customer_missing` (no customer on the payment), `card_not_saveable` (not a fresh card, e.g. a wallet), `payment_not_approved`. |
| `failed` | Attempted and failed; the payment still succeeded. `failure_code` names the reason (for example `VAULT_ADD_REFUSED`), `failure_message` carries the gateway's words when it gave some. |
`card_save` is **omitted**, never null, when the box was not ticked, when the payment did
not succeed, and when the result could not be read back (then `charge` is omitted too).
Saving never changes the payment's outcome.
**Do not wait for a webhook to settle `pending`.** A save that completes after the confirm
answered sends `payment_method.attached` only for a newly saved card,
`payment_method.updated` when it renewed a card the customer already had (same card, new
expiry), and no event at all when that card was already saved with the same expiry. Re-reading
the intent does not move a `pending` either. To settle it, read the customer's cards on your
server with `GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`).
**Two forms of the same id.** `card_save.payment_method` is the `pm_…` form, which is what
your server uses: `POST /v1/charges` takes it as `payment_method.payment_method_id`, and it
matches `GET /v1/customers/{id}/payment_methods`. The customer-session list below
(`savedCards()`, `GET /v1/customer_sessions/payment_methods`) returns the same card by its bare
id, without the `pm_` prefix. A browser confirm with a saved card takes either form, with the
customer session of the buyer who owns the card.
### Saving a card without a payment (setup intents)
A $0 trial sign-up, "add a payment method" on an account page, and a new card for a
subscription all need a saved card and no charge. Your server opens a setup intent for an
existing customer:
```bash
curl https://api.worthypayments.com/v1/setup_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: signup_42_card" \
-H "Content-Type: application/json" \
-d '{ "customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f", "usage": "off_session" }'
```
and mounts the element with its `client_secret` (`seti_…_secret_…`), exactly as for a payment:
```js
wp.mountPaymentElement('#card', {
clientSecret: setupIntentClientSecret,
onSuccess: (r) => {
// r.setupIntentId, r.payment_method ('pm_...'); r.paymentIntentId is '' — nothing was paid
saveOnMyServer(r.payment_method);
},
onError: (e) => { /* e.code: card_declined (with e.decline_code) or processing_error */ },
});
```
- The element draws the card fields (and the billing fields you ask for) with a **Save card**
button. It shows no saved cards, no save box and no wallet buttons: a wallet's device token
cannot be saved.
- The card is verified with a $0 authorization and saved to the customer in the same gateway
request, as the card's initial stored-credential transaction. Charge it later with
`POST /v1/charges` and `payment_method: { payment_method_id: 'pm_…' }`; a renewal is
`recurring: true`, `initiated_by: "merchant"`.
- A declined card leaves the form open for another card. After five failed attempts the setup
intent accepts no more: create a new one.
- 3-D Secure runs when you create the setup intent with `three_ds: { request: 'challenge' }`
(or `any` / `challenge_mandated`); the element shows the bank's challenge itself.
- Confirm on your server from `setup_intent.succeeded` (it names the saved card) or by
retrieving the intent, not from the browser callback alone. A failed attempt sends
`setup_intent.setup_failed`.
- `usage` (`off_session`, the default, or `on_session`) says what you will do with the card;
both save it the same way. As with a payment that saves a card, tell the buyer at your page
that the card is kept for later payments.
- Each attempt meets the same fraud screening as a payment (your blocked customers and
cards, card-testing detection, your velocity rules) before the card is verified. A refusal
reads like a decline: `last_setup_error` `card_declined` with `decline_code` `fraudulent`,
and it counts toward the five attempts. Which rule fired is never answered.
**Mount before the setup intent exists.** As with [`mode: 'payment'`](#mount-before-the-order-exists),
pass `mode: 'setup'` and a `createIntent` that creates the setup intent on your server when
the buyer presses Save card, and resolves its `client_secret`:
```js
wp.mountPaymentElement('#card', {
mode: 'setup',
createIntent: async () => {
const r = await fetch('/my-server/setup-intents', { method: 'POST' });
return (await r.json()).client_secret; // 'seti_..._secret_...'
},
onSuccess: (r) => saveOnMyServer(r.payment_method),
});
```
It takes no `amount`, `currency` or `setupFutureUsage` (`amount_with_setup_mode`,
`setup_future_usage_with_setup_mode`), and a payment intent's secret from `createIntent` is
refused (`invalid_client_secret`), so a Save card button can never charge.
**Cancel or list.** `POST /v1/setup_intents/{id}/cancel` (secret key, `customers:write`) ends
an open setup intent, for a buyer who left the form or a duplicate you created; pass
`cancellation_reason` (`abandoned` | `duplicate` | `requested_by_customer`) if you track it.
A canceled intent takes no card. A succeeded one cannot be canceled: its card is saved, so
detach the card instead. `GET /v1/setup_intents` (secret key, `customers:read`) lists your
setup intents newest first, `?customer=` for one customer's.
### Changing a subscription's card
A subscription's buyer who wants to pay with another card saves it with a setup intent for
the subscription's customer (above), then your server points the subscription at it:
```bash
curl -X PATCH https://api.worthypayments.com/v1/subscriptions/sub_9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "default_payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e" }'
```
Nothing is charged now; the next renewal charges the new card. The card must be an active
saved card of the subscription's own customer (another customer's card, or one you detached,
is a 404). A canceled subscription is refused. A `past_due` subscription keeps its retry
schedule, and its next retry charges the new card.
---
## Saved cards
To show a returning buyer their cards, create a **customer session** on your server
(scope **`customer_sessions:create`**, secret key). It is a short-lived grant for **one**
customer. Publishable keys cannot list your customer book.
A configured environment returns `201` with `client_secret`. `400
customer_sessions_unavailable` means this environment has no session HMAC secret
(`CUSTOMER_SESSION_TOKEN_SECRET`): the endpoint exists and fails closed rather than
minting an unsigned grant. Do not treat the 400 as a missing route.
```bash
curl https://api.worthypayments.com/v1/customer_sessions \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8"}'
```
```json
{
"object": "customer_session",
"customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8",
"client_secret": "cs_live_xxx",
"expires_at": 1754160000
}
```
`customer` accepts the `cus_` id or the bare uuid, and is answered as `cus_`.
The session lasts 30 minutes. `expires_at` is a Unix timestamp, so mint it when the buyer
reaches checkout rather than when the page first loads.
The `client_secret` from **this** response is what you pass as
`customerSessionSecret`. It is not the payment intent's client secret, and the two
names trip people up.
```js
wp.mountPaymentElement('#payment', {
clientSecret, // from /v1/payment_intents
customerSessionSecret: 'cs_live_xxx', // from /v1/customer_sessions
onSuccess: (r) => { /* .. */ },
});
```
The element renders the picker itself. If you want your own chooser instead,
`savedCards()` returns the list:
```js
const cards = await wp.savedCards(customerSessionSecret);
// [{ id: '6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e', card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2029 } }]
```
You get brand, last4 and expiry, which is enough to render a chooser. Vault ids and
instrument tokens stay server-side. `id` is the bare payment-method id (no `pm_` prefix);
pass it unchanged when you confirm with the saved card.
Creating customers is in [Customers](/developers/18-customers.md).
### 3-D Secure on a saved card
When a payment on a saved card needs 3DS — your intent's `three_ds.request` asked for
it, or one of your rules did (the route's **Attempt 3DS**, the 3DS risk band, a customer
marked Require 3DS, another fraud rule; see
[When does a step-up happen?](/developers/07-3ds.md#when-does-a-step-up-happen)) — the element runs
the challenge on the saved card itself, as it does for a typed card. This works because a
card saved through the element keeps an encrypted copy of its number that only our
encryption provider can decrypt. The buyer does not type anything.
A card saved before 2026-10-18, or imported from your gateway vault, has no encrypted copy.
Its attempt is declined with `decline_code: soft_decline_3ds` and **nothing is charged**.
The element then handles it:
1. It hides the saved cards and opens the card form, with the message "Your bank needs to
verify this card. Enter the card details again to continue. You have not been
charged."
2. The buyer types the card again and presses **Pay**. That card runs the challenge like
any card entered for this payment, and is charged when it authenticates. When it is
the same card, its encrypted copy is kept on the saved card, so the buyer is not asked
again next time.
`onError` fires once for the first attempt, with `code: card_declined`,
`decline_code: soft_decline_3ds` and that message. The element stays mounted and the
intent stays payable, as with any decline: do not unmount it or show a failure screen.
The "Save this card" box is not offered again, because the card is already saved.
The same decline on a card the buyer just typed is the issuer's own soft decline and is
shown as a normal decline.
---
## Styling
You name variables, we apply them inside the frame.
```js
wp.mountPaymentElement('#payment', {
clientSecret,
appearance: {
variables: {
colorPrimary: '#7C3AED',
colorText: '#E8EAED',
colorBackground: 'transparent',
borderRadius: '12px',
fontFamily: 'Inter, sans-serif',
},
rules: {
'.Input': { boxShadow: 'none' },
'.Input:focus': { borderColor: '#7C3AED' },
},
},
onSuccess: (r) => { /* .. */ },
});
```
You can recolor, restyle, narrow, and scroll the form inside the frame. You cannot
lift it out of the iframe (`position`, `display`, `transform` and `backgroundImage`
are dropped). The iframe box is still the host `#payment`. Unknown variables are ignored.
Full list: [Embedded SDK](/developers/08-embedded-sdk.md).
The **Pay button** is filled with the `colorPrimary` you name (`rules['.Button']`
`backgroundColor` overrides it); without one it keeps the default look. Change its
wording with `payButton: { label }`, or hide it with `payButton: { display: 'never' }`
and call `element.submit()` from your own button. Its states have their own selectors
(`.Button--disabled`, `.Button--loading`, `.Button--paid`), as do placeholders
(`.Input::placeholder`), the "save this card" checkbox (`.Checkbox`, `.CheckboxInput`,
`.CheckboxLabel`), the card brand mark (`.CardBrand`) and a saved card under the pointer
(`.SavedItem:hover`). For **fonts**, the frame serves Inter,
Roboto, Open Sans, Lato, Montserrat and Poppins itself, so `fontFamily: 'Inter, sans-serif'`
renders in Inter; a font only your page loads cannot reach the frame and falls back down
your stack.
---
## Wallets and 3-D Secure
Apple Pay and Google Pay mount on the **merchant page, directly above the card iframe**.
Not inside it. Apple's domain association and the loopback exemption are checked against
the origin the sheet runs on, so a wallet mounted inside our frame would be checked against
*our* origin instead of your registered domain. The PAN stays in the iframe; a wallet
carries a cryptogram.
Google Pay has one shape that does not: `PAN_ONLY`, a card saved in the buyer's Google
Account rather than tokenised on an Android device (the usual case in desktop browsers and
on iOS). It returns the card itself with no cryptogram. It is charged as an ordinary
card rather than as a wallet — so it is scored like one and can be stepped up to 3DS,
which a wallet cannot. Nothing changes on your side; the button simply shows for those
buyers too, where it used to be hidden from them entirely.
There is no separate mount call. The sheet total is loaded from the intent. `onCancel`
fires when the buyer dismisses a sheet. Pass `wallets: 'never'` if you already mount
your own. The buttons' theme, wording and height are `walletButtons` (Apple and Google
draw them, so `appearance` does not reach them).
A wallet payment answers on the same callbacks, in the same shapes, as the card form:
`onSuccess` receives the same result (receipt fields and `card_save` included), and a
decline reaches `onError` as `card_declined` with `declineCode` and the same buyer
sentence. The sheet stays open until the charge answers; on a decline it is failed with
that same sentence.
The sheet asks the buyer for an email, name, phone and billing address, and the element
sends what the wallet returned with the payment, so a wallet buyer gets a receipt and an
address for AVS. Values you pass in `defaultValues.billingDetails` win over the wallet's.
### Subscriptions paid by wallet
When the payment starts a subscription, say so with `recurring`:
```js
wp.mountPaymentElement('#payment', {
clientSecret, // the intent names a `customer`
recurring: {
description: 'Pro membership',
billingAgreement: 'You will be charged $9.99 every month until you cancel.',
managementUrl: 'https://shop.example/account/subscriptions',
regularBilling: { label: 'Pro', amount: 999, intervalUnit: 'month' },
// trialBilling: { label: 'First week free', amount: 0 },
},
onSuccess: (r) => { /* r.card_save says whether a renewal method was kept */ },
});
```
| Field | Required | Default | Meaning |
|---|---|---|---|
| `description` | Yes | — | What the buyer subscribes to, shown on the Apple Pay sheet |
| `billingAgreement` | Yes | — | The terms shown on the sheet before the buyer authorizes |
| `managementUrl` | Yes | — | An `https` page where the buyer manages or cancels |
| `regularBilling.label` / `.amount` | Yes | — | The repeating charge; `amount` in minor units, above zero |
| `regularBilling.intervalUnit` | No | `month` | `day`, `week`, `month`, `year` |
| `regularBilling.intervalCount` | No | `1` | Every N units |
| `regularBilling.startDate` | No | now | When the repeating charge starts (ISO-8601 or `Date`) |
| `trialBilling` | No | — | `{ label, amount, startDate? }`, an introductory period; `amount` may be 0 |
It does two things, both on the wallet buttons only:
- **The Apple Pay sheet opens as a recurring payment request.** The buyer sees the
description, the agreement, the cadence and the link, and Apple can issue a token that
belongs to your relationship with the buyer rather than to their phone.
- **A wallet payment asks to save its payment method**, as a ticked "save this card" does
on the card form, and `onSuccess` receives [`card_save`](#did-the-card-get-saved). The
subscription is the consent, so state its terms on your page. The intent needs a
`customer`, or `card_save` is `skipped` / `customer_missing`.
- **Google Pay offers only the cards it can hand back as the card itself** (`PAN_ONLY`).
A device token cannot be saved, so a subscription paid with one would end at its first
payment.
What `card_save` says today, by wallet:
| The buyer paid with | `card_save` | For renewals |
|---|---|---|
| Google Pay (with `recurring`, always the card itself, `PAN_ONLY`) | `saved`, with a `pm_…` | Charge that `pm_…` like any saved card |
| Apple Pay | `skipped` / `card_not_saveable` | Collect a card for renewals |
An Apple Pay token cannot be saved yet: the gateway has to accept wallet tokens into its
vault first. To keep Apple Pay off a subscription, use the express element with
`paymentMethods: { applePay: 'never' }` ([`mountExpressCheckout`](/developers/08-embedded-sdk.md#mountexpresscheckouttarget-opts)). The sheet still shows the subscription terms. The amount the buyer
authorizes now is still the intent's total; `regularBilling` describes what follows.
A malformed `recurring` (a missing field, an `http` link, a fractional amount) reaches
`onError` and the Apple Pay button is not drawn. Mount-only. The card form is unchanged:
it keeps following `fields.savePaymentMethod`.
### Wallet config may be none
Do not assume Apple Pay or Google Pay are on. The SDK reads `GET /v1/wallet-config` (scope
`none`). Both flags false is the default, not an error, no buttons mount:
```json
{
"object": "wallet_config",
"mode": "test",
"provider": "NONE",
"applePay": false,
"googlePay": false,
"evervaultMerchantId": ""
}
```
Mount a button only on a literal `true`. Treat the flags as fail-closed.
**Dashboard (in-repo today).** Merchant Dashboard → **Settings → Checkout Settings →
Digital Wallets**. Switch Apple Pay / Google Pay on and save.
- **Test mode:** provision a sandbox wallet with **Create test wallet** on the same
page, then flip the switches. Google Pay needs only that record; Apple Pay also
needs a registered domain.
- **Live mode:** the switches stay disabled until support provisions a live wallet
merchant record. Contact support; there is no self-serve live provisioner.
Until `wallet-config` reports a wallet on, the element is card-only. That is expected.
**CSP only when a wallet is actually on** (scripts load on your page, not in the iframe):
```
script-src https://applepay.cdn-apple.com https://pay.google.com
frame-src https://pay.google.com
```
Miss them and the buttons silently do not render. Safari blocks the Apple script without a
console error, check CSP before anything else.
### 3-D Secure and `return_url`
When a charge is stepped up, the element drives 3-D Secure itself: it mounts the challenge
once, inside the element's own frame (the frame grows to fit it), then resubmits
automatically. If the issuer authenticates without a challenge, no challenge is shown; the
[overlay](#the-3-d-secure-overlay) says the card is being verified until the outcome arrives.
There is nothing to wire. Driving 3DS yourself (native app or server-driven flow) is
[3-D Secure](/developers/07-3ds.md).
Paygeyt has no `return_url` field on PaymentIntents or on `mountPaymentElement`. Stripe
uses `return_url` for a full-page redirect after 3DS; here the element stays on the
checkout page and the required `onSuccess` callback is the return path. Do not send
`return_url`.
### The 3-D Secure overlay
While 3-D Secure runs for a confirm from the card form, a payment or a card saved with a
setup intent (the bank collecting device data, a frictionless authentication, a challenge,
and the confirm after it), the element
covers your whole page: a dimmed layer with a spinner and "Verifying your card…"
("Verificando tu tarjeta…" for a Spanish `locale`). It is on by default and has no option,
as Stripe draws its own 3-D Secure UI over the merchant's page. A payment with no 3-D Secure
shows no overlay.
- **The challenge sits above it.** The bank's screen opens inside the element's frame; the
overlay leaves the frame's area uncovered while it is open, and scrolls the frame into view
if it is off screen. Nothing of yours is lifted or restacked.
- **Your page cannot be used underneath.** Clicks are blocked and focus is kept on the overlay
(on the frame while the challenge is open), so your own "Place order" button cannot be
pressed twice. `element.submit()` during a payment is ignored, as it always was.
- **Escape dismisses nothing**, the same as inside the bank's screen; the challenge's own
control is the way out. The key does not reach your page's handlers while the overlay is up,
and `onEscape` is not called, so a dialog of yours that closes on Escape stays open.
- **It comes down with the outcome**: success, decline, error, a failed or abandoned
challenge. It is gone before `onSuccess` or `onError` runs, so whatever you show there is not
drawn under it, and focus goes back where it was. `element.destroy()` removes it too.
- **Nothing moves.** It is a fixed layer, the page is not scroll-locked and nothing is
injected into your stylesheet, so it needs no CSP change.
- **It follows `appearance`:** the card's background, text colour, font and corner radius,
and the spinner's `colorPrimary`. The dimmed layer is the same on every page. The spinner
stands still for a buyer who asked for reduced motion.
If your page shows its own busy state while a payment runs, keep it: the overlay is drawn
above everything else on the page (`z-index: 2147483647`).
---
## Errors worth recognising
| Code | Cause |
|---|---|
| `card_declined` | Arrives on `onError` (`type: 'card_error'`), with `declineCode` when the decline has one. The element stays mounted; let the buyer retry, except after `duplicate_transaction`. |
| `three_ds_failed` / `three_ds_cancelled` | Arrives on `onError` (`type: 'card_error'`): 3-D Secure did not authenticate the card, or the buyer closed the challenge. Nothing was charged and the element stays mounted. `message` is the sentence the buyer read, in the element's `locale`: "Your bank could not verify this card. Try again or use another card." or "Card verification was cancelled. You have not been charged." |
| `amount_changed` | Apple Pay / Google Pay only: the intent's total changed after the sheet was drawn. Nothing was charged; the buttons redraw with the new total. |
| `request_timeout` | The confirm and the read after it both failed to answer. The outcome is unknown: check the intent before letting the buyer pay again. |
| `element_load_failed` | On `onLoadError`, not `onError`: the form never loaded. Check the key's allowed domains and your CSP. |
| `parameter_unknown` | An `amount` or `currency` reached confirm. The intent fixed those. |
| `secret_key_in_browser` | An `sk_` reached the browser. Use `pk_` plus a client secret. |
| `resource_missing` | No such intent for this key's merchant and mode. |
| `container_not_found` | The selector you passed matched no element. |
The full table is in [Errors](/developers/10-errors.md); the tokenization codes are in
[Error codes](/developers/30-error-codes.md).
Test cards and how to force a decline or a 3DS challenge are in
[Testing](/developers/15-testing.md).
========================================================================
# Idempotency
Network requests fail. A POST can time out *after* the server charged the card but
*before* your code saw the response, so a blind retry would charge twice. Idempotency
keys make a retried request return the **original** result instead of running again.
> **The `Idempotency-Key` header is REQUIRED, not optional**, on every money-moving
> endpoint. Omit it and the request is rejected with `400 idempotency_key_required`
> before anything is charged:
>
> | Endpoint | |
> |---|---|
> | `POST /v1/payment_intents` | create a Payment Element intent (names the amount) |
> | `POST /v1/charges` | create a charge |
> | `POST /v1/charges/:id/capture` | capture an authorization |
> | `POST /v1/charges/:id/void` | void an authorization |
> | `POST /v1/refunds` | refund a charge |
> | `POST /v1/subscriptions` | create a subscription |
> | `POST /v1/3ds/sessions` | open a 3DS session |
> | `POST /v1/3ds/retry-decline` | retry a soft decline |
>
> Everything else is read-only or naturally safe to retry, and ignores the header.
> Confirming an intent (`POST /payment_intents/:client_secret/confirm`) does **not**
> take an idempotency key: the client secret already addresses one unpaid session.
On `POST /payment_intents`, `POST /charges`, `POST /3ds/sessions` and
`POST /3ds/retry-decline` you may send the key in the JSON body as `idempotency_key`
instead of the header. The header wins if both are present; one of the two must be
there.
---
## How to use it
Send an `Idempotency-Key` header with a value you generate; a UUID v4 is ideal. Reuse
the **same** key when you retry the **same** request:
```bash
curl https://api.worthypayments.com/v1/charges \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: 5f3b9c1e-7a2d-4e88-9f1a-2c4d6e8b0a11" \
-H "Content-Type: application/json" \
-d '{"amount":4999,"currency":"usd","card":{"number":"ev:...","cvv":"ev:...","exp_month":"12","exp_year":"30"}}'
```
- **First call** with that key → runs normally, the result is stored.
- **Any retry** with the same key → returns the **stored** response (same status, same
body), with headers **`Idempotency-Replayed: true`** and **`X-Idempotency-Replayed:
true`** (same value; the `X-` name is the documented alias). The card is **not** charged
again. A request with no Chrome `User-Agent` never reaches this layer, Cloudflare
answers **1010**.
Always generate a fresh key per logical operation. Two genuinely different charges must
use two different keys, or the second will replay the first.
> **Stable per order, not per click.** Use something tied to the payment you mean once
> (your order id, cart id, or a UUID you store with that order). Do **not** call
> `crypto.randomUUID()` on every “Pay” press. A double-submit or network retry then
> becomes a **second charge**. The [reference checkout](/demos/checkout-reference.html)
> derives the key from the order for this reason.
---
## Scope and lifetime
| Property | Value |
|---|---|
| Header | `Idempotency-Key` (required) |
| Applies to | `POST /payment_intents`, `POST /charges`, `POST /charges/:id/capture`, `POST /charges/:id/void`, `POST /refunds`, `POST /subscriptions`, `POST /3ds/sessions`, `POST /3ds/retry-decline` |
| Stored for | **24 hours**. After that the same key runs fresh |
| Scope | Per API key. Your keys never collide with another merchant's |
| Replay marker | `Idempotency-Replayed: true` and `X-Idempotency-Replayed: true` |
The key is namespaced to *your* API key, so you can use any value scheme you like
(`order-1234-charge` works as well as a UUID) without worrying about global collisions.
---
## Concurrent retries
If two requests with the same key arrive **at the same time** (e.g. an aggressive retry
fires before the first finished), the second one gets a **409**:
```json
{
"error": {
"type": "invalid_request_error",
"code": "idempotency_in_progress",
"message": "Idempotency key is currently processing"
}
}
```
This is your signal to wait a moment and retry. The first request is still running. Once
it completes, retrying the same key replays its stored result.
**The in-progress claim expires after 10 minutes.** If the first request never settles
(the process died mid-charge, the connection dropped before a result was recorded) the key
stays locked for ten minutes and then becomes claimable again, so a stuck key cannot wedge
an order forever. The tradeoff is the reason for the number: while the claim holds, a
retry is correctly refused; once it lapses, a retry runs the request **for real** rather
than replaying. Ten minutes is comfortably longer than any charge takes, so in practice
the lapse only fires when the first attempt truly never completed.
Backing off on a 409 is therefore the right move, and giving up on it is not: keep retrying
the same key with a short delay until you get a real answer. Switching to a fresh key after a
409 is the one thing that turns a protected retry into a double charge.
---
## What gets replayed
- **Success (2xx):** stored and replayed for 24h, with one exception: a `pending` charge
whose outcome is not known yet (see [below](#a-pending-charge-is-not-a-final-answer)).
- **A card decline (402) raised by the charge itself:** stored and replayed, so a retry
returns the same decline rather than attempting the card again. A gateway refusal
(`decline_code` `duplicate_transaction`, `processing_error`, …) is a decline like any
other and is replayed the same way.
- **A fraud block 400 (`FRAUD_BLOCKED` / `failed_precondition`), pre-screen or scored:**
stored and replayed exactly. A scored block is terminal under its key: the retry returns
the same refusal naming the same failed `charge`, records no second charge and sends no
second `charge.failed` webhook. A 400 without `charge` (a pre-screen block, or a scored
block whose charge id was not returned) is replayed for 24 hours exactly as it was, still
without `charge`, even if the failed charge was recorded and its webhooks fired. A retry
must not come back as a different 402 `card_declined`. Trying again after a block takes a
new key, which is a new attempt and is screened again. See [Fraud
blocks](/developers/10-errors.md#fraud-blocks-fraud_blocked).
- **Server errors (5xx), 408, 409, 429:** **not** stored. The key is released so a genuine
retry can run the request again.
- **Other settled 4xx (the envelope the client actually received):** stored and replayed.
The edge no longer releases a DONE 4xx on a later null settle. That is what turned a 400
`FRAUD_BLOCKED` into a 402 on retry.
---
## A pending charge is not a final answer
`POST /v1/charges` answers `status: "pending"` when the gateway did not tell us in time
whether the card was charged (a timeout or a broken connection after the request may have
reached it). That `pending` is **not** stored under your key: the key is released at once.
- **Retry with the same key** after a short delay. The payments service first asks the
gateway what happened to that attempt. If it charged, you get that charge back (nothing is
charged twice). If it provably did not, the retry runs the payment, once. While the answer
cannot be established yet, the retry gets `409` `idempotency_in_progress`: wait and retry
the same key again until you get a charge or a `402`.
- **Never retry it under a new key.** If the first attempt lands, that is a second charge.
- You can also follow the charge by its `id` (`GET /v1/charges/{id}`, or the
`charge.succeeded` / `charge.failed` webhooks): a pending charge resolves on its own within
minutes.
Only this unknown outcome is released. A pending ACH debit (accepted by the bank network,
settling over days) is a real answer and is stored and replayed for 24 hours as usual.
---
## Reusing a key with a different body
A key is bound to the request that claimed it. Send the same `Idempotency-Key` with a
**different body** and the call is rejected with `400 idempotency_error`. It does not
replay the first response, and it does not run the second request.
The comparison is a hash of the fields that decide the money: for a charge that is
`amount`, `currency`, capture mode and payment method; for a refund it is the whole body,
so adding a `reason` to an otherwise identical retry counts as a different request. This
matters if you key on your order id, which is otherwise good practice: an order whose
amount changes (cart edit, tax recalculation) must claim a new key rather than reuse the
old one.
---
## Where the guarantee lives
Re-running is safe rather than a second charge because the payments service keeps its own
idempotency record keyed off the same request: it replays its stored outcome instead of
authorizing again. The edge cache is a latency and consistency layer on top of that, not
the only thing standing between you and a double charge.
One consequence worth planning for: the edge's replay cache is best-effort. If its store
is unavailable the cache degrades to a per-process fallback, which does not span
instances. The downstream record still holds, but you should not treat an edge replay as a
guarantee you can build settlement logic on. Reconcile on the charge id.
---
## The key is required
On the money-moving POSTs listed at the top of this page the header is **mandatory**.
Omitting it does not run the request unprotected. It is rejected outright, before anything
is charged:
```json
{
"error": {
"type": "invalid_request_error",
"code": "idempotency_key_required",
"message": "Idempotency-Key header is required for this request",
"param": "Idempotency-Key"
}
}
```
On `POST /charges`, `POST /3ds/sessions` and `POST /3ds/retry-decline` an
`idempotency_key` field in the JSON body satisfies the requirement instead; the header
wins when both are present.
Every other endpoint is read-only or naturally safe to retry, and ignores the header.
---
## A second guard you do not control: the acquirer's own duplicate window
Idempotency above is **ours**, and it is keyed on your `Idempotency-Key`. Your acquirer
runs a second, independent check that is keyed on the **payment itself** (same card and
same amount inside a window) and it does not know or care what key you sent. NMI's default
window is 20 minutes. On NMI's sandbox the order reference is not part of the check: two
charges with different order references on the same card and amount were still refused.
That means the two can disagree, and the case that surprises people is this one:
```
POST /v1/charges Idempotency-Key: order_A $9.11 on card ...1111 -> succeeded
POST /v1/charges Idempotency-Key: order_B $9.11 on card ...1111 -> failed
```
A **new** key, so our replay logic correctly treats it as a new payment, and the acquirer
still refuses it as a duplicate. The second call comes back at once as a `402` with `code:
"card_declined"`, `decline_code: "duplicate_transaction"` and the gateway's own text in
`message` (`Duplicate transaction REFID:…`). Read back, the charge is `failed` with
`outcome.network_status: "not_sent_to_network"`: the card network never saw it. Through the
Payment Element the same refusal reaches `onError` as `card_declined` with `declineCode:
"duplicate_transaction"`. See [Gateway refusals](/developers/10-errors.md#gateway-refusals).
**This is a protection, not a defect.** It is what stops a double-submitted cart or a
retrying job from charging a cardholder twice when the retry legitimately carries a
different key. Treat it the same way you treat a decline: surface it, do not hammer it.
What to do about it:
- **Do not retry the identical charge to "get past" it.** A retry inside the window fails
the same way, and outside it you risk the second charge actually going through.
- **Know what a genuine second payment looks like to the acquirer.** Two separate orders
for the same amount on the same card are unusual but legitimate. A different order
reference tells them apart only where the acquirer includes it in its check; NMI's
sandbox does not, so the second is refused until the window passes.
- **Watch for it in testing.** Firing the same fixture amount repeatedly against a sandbox
is the fastest way to hit this, and it reads as "the money path broke" when nothing did.
Vary the amount between test runs.
The window length and whether it can be tuned at all are set by the acquirer, not by this
API, some refuse to let it be adjusted per charge. You cannot switch it off from the
request.
========================================================================
# Webhooks
Webhooks notify your server when something happens, most importantly when a charge
succeeds. They let your backend react to events without polling the API.
---
## Registering an endpoint
Register an endpoint from the dashboard or over the API. Both manage the same objects.
**Dashboard:** Developers → Webhooks → Add endpoint. Enter your HTTPS URL and select the
events you want.
**API** (requires the `webhooks:write` scope, or `webhooks:read` for the reads):
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/v1/webhook-endpoints` | Create an endpoint |
| `GET` | `/v1/webhook-endpoints` | List endpoints |
| `GET` | `/v1/webhook-endpoints/{id}` | Retrieve one endpoint |
| `DELETE` | `/v1/webhook-endpoints/{id}` | Delete an endpoint |
| `POST` | `/v1/webhook-endpoints/{id}/roll-secret` | Roll the signing secret |
Either way you get a signing secret used to verify deliveries (see below).
---
## Events
Subscribe only to events you handle. The charge / capture path commonly emits:
| Event | When | Payload |
|---|---|---|
| `charge.succeeded` | Sale capture, Payment Element success, or manual capture settles | `transactionId`, `amount`, `instrument`, and `detail` (see [below](#the-detail-block)) |
| `charge.authorized` | `capture_method: manual` (or delayed capture) holds funds | The same set, plus `detail` |
| `charge.failed` | Decline / hard failure, including a charge our fraud screening blocked ([below](#fraud-blocks)) | The same set, plus the decline summary and `detail` |
| `charge.canceled` | Authorization voided | `transactionId`, `amount`, `instrument`, `orderReference`, `metadata`. **No `detail`** |
| `charge.expired` | An authorization nobody captured ran past the card network's window and was canceled (it also fires `charge.canceled`) | The same set as `charge.canceled`. **No `detail`** |
| `charge.refunded` | Full or partial refund | The same set, plus `refundAmount`, `parentTransactionId` and `detail` |
| `dispute.created` | A dispute was opened (also fires for a pre-dispute alert) | Dispute event (see [below](#dispute-payloads)) |
| `dispute.won` / `.lost` | The dispute was decided (each also fires `dispute.closed`) | Dispute event; `.lost` adds `fee` |
| `dispute.updated` | Evidence was submitted, or the dispute left the sale it named | Dispute event (see [link changes](#link-changes)) |
| `dispute.funds_withdrawn` / `.funds_reinstated` | Disputed funds left or returned to your balance | Dispute event |
| `pre_dispute_alert.created` | Ethoca / CDRN / RDR alert received, ahead of any chargeback | Alert event (see [below](#alert-payloads)) |
| `pre_dispute_alert.updated` | The alert moved: matched to a sale, unlinked or moved to another sale, refunded, or resolved | Alert event (see [below](#alert-payloads)) |
That is the payments subset. The rest covers customers, checkout sessions, orders,
subscriptions, invoices, disputes, decline recovery, and payouts. See the [full
catalogue](/developers/19-events.md). Dashboard → **Webhooks → Add endpoint** lists the deliverable
names, served from the same API that validates `enabled_events`, so that list is the
authority on what exists rather than any count written down here.
### Fraud blocks
A charge our scored fraud screening refuses is recorded as a failed charge, so it raises
`charge.failed` and `transaction.failed` once, like any decline, whichever flow it came
from: `POST /v1/charges`, a payment intent, the hosted checkout or a subscription charge.
Its decline summary says that we refused it, not the issuer:
```json
{
"type": "charge.failed",
"data": { "object": {
"transactionId": "TXN-01JABCDEFGHJKMNPQRSTVWXYZ0",
"amount": { "minorUnits": "2500", "currencyCode": "USD" },
"instrument": { "brand": "visa", "last4": "4242" },
"decline": {
"category": "BLOCKED",
"declineCode": "fraudulent",
"declineOrigin": "PLATFORM"
},
"detail": {
"orderReference": "ORD-77431",
"declineCode": "fraudulent",
"declineReason": "Your card was declined."
}
} }
}
```
- **`decline.declineOrigin: "PLATFORM"`** means the platform refused the charge before any
gateway call. No processor saw it, so `mid`, `processor`, `gateway`, `acquirerCode`,
`acquirerLabel` and `decline.networkDeclineCode` are absent. An issuer decline reads
`"NETWORK"` and a gateway refusal `"GATEWAY"`.
- **`transactionId`** is the `charge` the `POST /v1/charges` 400 names; `GET
/v1/charges/{id}` reads it back with `outcome.type: blocked`.
- **No rule or reason code** is in the payload. The score and decision are on `GET
/v1/charges/{id}/risk`.
- **Once per block.** A retry under the same `Idempotency-Key` replays the refusal and sends
no second webhook.
- **A fraud pre-screen (velocity) block sends no webhook.** It is visible on the charge
list, but it raises no event.
### Subscriptions, invoices and orders
These carry the **whole record**, not just an id and a total, so a webhook-only
integration can act without calling back.
| Event | When | Payload |
|---|---|---|
| `subscription.created` / `.updated` | Created, or plan/quantity/trial changed | `detail`, see [below](#the-subscription-block) |
| `subscription.renewed` | A new billing period started | `detail`, incl. `amount`, `interval` and `currentPeriodEnd` |
| `subscription.past_due` | A renewal charge failed | `detail`, incl. the recurring `amount` and `currentPeriodEnd` |
| `subscription.suspended` | A hard decline ended the retries | `detail` |
| `subscription.canceled` / `.paused` / `.resumed` | Lifecycle change | `detail`, incl. `cancelAtPeriodEnd` and `cancellationReason` |
| `subscription.usage_exceeded` | Metered usage passed the limit | `currentUsage`, `usageLimit`, `percentOver`. **No `detail`** |
| `invoice.created` / `.finalized` | Drafted, or finalized (number and URLs stamped) | `detail`, see [below](#the-invoice-block) |
| `invoice.sent` | We emailed it to your customer | `detail`, plus a top-level `hostedInvoiceUrl` and `checkoutUrl`, both live |
| `invoice.overdue` | Past its due date, unpaid | `detail`, incl. `amountRemaining` |
| `invoice.paid` / `.payment_succeeded` | `amountRemaining` reached zero | `detail`, incl. the lines |
| `invoice.voided` | Voided | `detail`. Check `amountPaid`: a void can still owe a refund |
| `order.created` / `.paid` / `.fulfilled` / `.cancelled` | Order lifecycle | `order`, the stored order row minus its relations |
| `payment_link.created` / `.updated` / `.deactivated` | A link's own lifecycle | Link id, code, and on `.updated` a `changedFields` list |
| `payment_link.completed` | A checkout **through** a link finished | Checkout session |
| `inventory.low_stock` | A store product hit its low-stock threshold on a sale | `productId`, `storeId`, `quantityAfter`, `threshold` (but see the zero-value rule below) |
There is **no `invoice.payment_failed`**. A card renewal that declines is
`subscription.past_due`. Do not wait for an invoice event that is not in this
catalog.
> **`payment_link.updated` names the fields that changed, not their new values.**
> `changedFields` is `["amount"]`, not the amount. Re-read the link when you need
> the value. This is deliberate: the event rides a topic that is not scoped to the
> link's contents.
> **`store.*` and `product.*` are not merchant events.** They record administrative
> changes we make to a catalogue, not changes you make, so they are not deliverable.
> Your own product edits are yours already.
> ⚠️ **`subscription.renewed`, `.past_due` and `.suspended` also deliver as
> `subscription.updated`.** An endpoint subscribed to both names receives every
> renewal twice, under two names with one shared occurrence. Subscribe to one or
> deduplicate on the subscription id and the timestamp. This is the same
> two-surfaces rule that applies to `charge.*` and `transaction.*`.
> ⚠️ **`charge.succeeded` and `charge.captured` are the same delivery moment, not two
> stages.** One captured sale fans out to `transaction.succeeded`, `charge.succeeded`
> **and** `charge.captured`, and it does so for an ordinary auto-capture sale and a
> recorded cash sale exactly as it does for an authorization you capture later. So
> `charge.captured` is not "the second half of auth-then-capture". An endpoint subscribed
> to both `charge.succeeded` and `charge.captured` receives **every sale twice**. Pick one
> name. `charge.authorized` is the event that means funds are held and nothing has
> settled.
> ⚠️ **`order.created`, `order.paid` and `order.completed` all fire on the same order, at
> the same instant.** An order row is written already paid, and that single write emits
> all three names, `order.completed` is an alias of `order.paid`, and `order.created` is
> not an earlier lifecycle step you can act on first. An endpoint subscribed to all three
> receives one order three times. Subscribe to one.
> ⚠️ Marking an order shipped and adding a tracking number are separate writes, and each
> is a fulfilment fact. Marking it shipped emits `order.fulfilled` with
> `order.trackingNumber` empty; adding a tracking number the order did not already hold
> emits `order.fulfilled` again, that time carrying the number. Either write can come
> first, and when the tracking number arrives together with the shipping mark there is
> only one delivery. So key your handler on the order id and treat a second
> `order.fulfilled` as an update rather than a duplicate, re-marking an already-shipped
> order, or re-sending a tracking number we already hold, emits nothing further.
> ⚠️ **`inventory.low_stock` omits `quantityAfter` when the product sold out.** The field
> is a plain integer, and the payload drops zero-valued fields entirely (see the
> [zero-value rule](#omitted-values)), so the sold-out case (the alert you most want to
> act on) arrives with **no `quantityAfter` key at all** rather than `quantityAfter: 0`.
> Read it as `Number(o.quantityAfter ?? 0)`. `threshold` behaves the same way for a
> merchant who set their alert threshold to zero.
Every delivery is the same envelope. Your payload is at **`data.object`**, one level
deeper than `data`. Reading `data.id` returns `undefined`.
```json
{
"id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"object": "event",
"api_version": "2026-01-01",
"created": 1733673600,
"livemode": false,
"type": "charge.succeeded",
"data": {
"object": {
"transactionId": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"processorTransactionId": "1234567890",
"amount": { "minorUnits": "2000", "currencyCode": "USD" },
"merchantAccountId": "cmt3yfavu00013iokngc9rjtw",
"mid": "9876543",
"processor": "NMI",
"instrument": { "brand": "visa", "last4": "4242", "bin": "424242" },
"customerRef": "ae7babf2-8c52-43fe-b2ae-c3395b17add8",
"riskScore": 12,
"occurredAt": "2026-08-13T10:15:00Z",
"envMode": "ENV_MODE_TEST",
"idempotencyKey": "order_1234",
"ip": "203.0.113.7"
}
}
}
```
Three things to read carefully before you write the handler:
- **`data.object` keys are camelCase, and the envelope's are snake_case.** The envelope
(`api_version`, `livemode`) is ours; `data.object` is a protobuf message rendered with
the canonical JSON mapping, which converts `transaction_id` to `transactionId`. So it is
`e.data.object.transactionId`, not `e.data.object.transaction_id`. The snake_case form
returns `undefined`. This applies to every event type, disputes included.
- **`amount` is an object, not an integer.** `amount.minorUnits` is the integer cents *as
a JSON string* (protobuf renders 64-bit integers that way), and `amount.currencyCode` is
uppercase ISO-4217. `Number(e.data.object.amount.minorUnits ?? 0)` gives you `2000`.
Note this differs from the REST API, where `amount` is a plain integer and `currency` is
lowercase. **The `?? 0` is not defensive habit. A zero money really does arrive as
`{"currencyCode":"USD"}` with no `minorUnits` key.** See the [zero-value
rule](#omitted-values).
- **The field names are not the REST field names.** `data.object` is the event as the
engine emits it, not the charge object `POST /v1/charges` returns: the id is
`transactionId`, the card is `instrument`, there is no `status` field, and the `metadata`
you sent on `POST /v1/charges` (or on the payment intent the charge was made from) is under
`detail` (`detail.metadata`) — at the top level only on `charge.canceled` /
`charge.expired`, which have no `detail`.
The outcome is already in the event `type`. `processor` is Admin → Acquirers → **Code**
of the acquirer **Payment Workflow** chose for this sale (`NMI`, `PAYARC`, `NUVEI`,
`MAVERICK`, … as configured). Those processors share one NMI security key; the NMI
`processor_id` on the sale is that row's charge code (Connector code (`processor_id`)).
Connector codes and that charge code are not this field. `mid` is omitted when the row
has none. `processorTransactionId` is the gateway's own id (NMI `transactionid`).
**Disputes and pre-dispute alerts are the exception**: they nest the REST object itself
under `data.object.dispute` / `data.object.alert`, shaped by the same mapper the read
API uses. See [Dispute payloads](#dispute-payloads) and [Alert
payloads](#alert-payloads).
- **Inside `data.object`, empty *and zero* fields are omitted, not nulled.** That half of
the body is protobuf rendered with the canonical JSON mapping, and that mapping drops
every field still holding its default: unset strings (`customerRef`, `ip`,
`idempotencyKey`), the number `0`, `false`, and an enum sitting on its first value. So
there is no `""`, no `0` and no `false` anywhere under `data.object`. The key is simply
not there. Read every field defensively (`e.data.object.customerRef ?? ""`, `Number(x ??
0)`, `flag === true`) and never test for a key's presence to decide whether something
happened. The envelope around it is ours and does not behave this way: `livemode` is a
real `false` on a test-mode delivery.
### Matching a delivery back to your order
**On the money events, read `data.object.detail.orderReference`. It is your own
reference, echoed back.** That is the direct join, and it is the one to use on:
| event | |
|---|---|
| `charge.authorized` / `transaction.authorized` | |
| `charge.succeeded` / `charge.captured` / `transaction.succeeded` | |
| `charge.failed` / `transaction.failed` | a decline carries it too |
| `charge.refunded` / `transaction.refunded` | matches the refund to the original order |
| `charge.canceled` / `transaction.voided` | reads it at the **top level**, not under `detail`, as is `metadata`; a void raised from settlement carries neither |
| `charge.expired` / `transaction.expired` | top level, as on `charge.canceled` |
`detail` carries the rest of the record with it: the buyer as you supplied them,
what the gateway answered (`authCode`, `responseCode`, `avsResult`) and the card
expiry. On the refund events it also carries the settlement trail (`arn`,
`settlementBatchId`) as it arrives. See [The `detail` block](#the-detail-block) below.
> ⚠️ **Where `detail` rides, exactly.** It is on the first four rows of the table above
> (authorization, capture, decline, refund, under both name families), on
> `transaction.enriched`, on every `subscription.*` event except `usage_exceeded`, on
> every `invoice.*` event, and on `customer.created` / `customer.updated`. It is **not**
> on `charge.canceled` / `transaction.voided` or `charge.expired` / `transaction.expired`,
> not on `transaction.updated`, and not on
> either `charge.three_d_secure.*` event. Three families carry a different shape instead,
> and none of them has a `detail` key: `dispute.*` and `pre_dispute_alert.*` nest their
> REST resource (`data.object.dispute`, `data.object.alert`) and are joined through the
> `transactionId` they name; `order.*` nests the order row at `data.object.order`.
> `customer.deleted` carries neither; by then there is nothing left to send.
> `customer.created` / `.updated` carry no transaction id at all: they are keyed on the
> customer id, and `detail` is the customer record, not a payment.
> ⚠️ **It is a snapshot of what we stored, so it can be absent.** `orderReference` is what
> you sent us at charge time; a field you never sent is not invented here. Read
> defensively (`detail?.orderReference ?? ""`) rather than assuming the key exists. The same
> goes for `detail.metadata`, the `metadata` you sent on `POST /v1/charges` or on the payment
> intent the charge was made from: it is omitted
> when you sent none, on a charge made before it was stored, and on a refund synced from your
> gateway (a refund issued here repeats the original sale's). Read it as
> `detail?.metadata?.order_id`. The whole block is optional in the contract, so keep reading
> it as `detail?.…`.
> It used to be genuinely missing on the gateway-sync lanes. The ones that record
> transactions we observe at the processor rather than take through the API, and the
> largest source of declines here. That gap is closed: every producer of an event whose
> payload declares `detail` now attaches it, built from the row it just wrote rather than
> from the observation that arrived. An absent block would mean *not loaded*, which is a
> different fact from a present block whose fields are empty. Keep the payment-intent
> fallback below wired as your second join rather than your emergency one.
**The fallback, when you have no reference of your own.** If you did not set an
`orderReference` at charge time, link the payment intent to your order on the way in:
1. When your server creates the payment intent, store the returned `id` (`pi_…`)
**and** `client_secret` against your order.
2. The browser's `onSuccess` hands you `paymentIntentId`. The same `pi_…`. That is your
first, immediate confirmation, and it is already tied to your order.
3. To resolve the intent to the transaction the webhook will name, read `GET
/v1/payment_intents/{client_secret}` (the secret, **not** the `pi_…` id) and take
**`latest_charge`**. That value is the `transactionId` you will see in `data.object`.
(`latest_charge` is snake_case because it is a REST field; the webhook renders the same
value under a camelCase key.) A retrieve with only `pi_…` is a 404.
Either way, a handler must be idempotent rather than assume it sees each event once.
`metadata` carries your order id the same way, whichever way you charge. Set it on
`POST /v1/charges`, or on the payment intent (`POST /v1/payment_intents`, or `PATCH` before
the buyer pays), whose charge copies it when the buyer pays: it comes back as
`data.object.detail.metadata` on the charge events, and on `GET /v1/charges/{id}`. That is
where the `metadata` examples elsewhere in these docs apply.
> Deliveries are **asynchronous**. A charge never waits on your endpoint. If your server
> is slow or down, the charge still completes and the webhook is retried independently.
> Don't treat the API response and the webhook as a strict ordering; either may arrive
> first.
### The `detail` block
On the money events, `data.object.detail` carries the record as we stored it, so a
webhook-only integration does not have to call back for the rest of a transaction.
```json
{
"type": "charge.refunded",
"data": { "object": {
"transactionId": "TXN-337b3790-1bc1-436e-baf3-dbe688b393fb",
"parentTransactionId": "cmrze8mzt000101lqu51gwhp9:12480511238",
"amount": { "minorUnits": "7060", "currencyCode": "USD" },
"refundAmount": { "minorUnits": "7060", "currencyCode": "USD" },
"instrument": { "brand": "visa", "last4": "5013" },
"gateway": "NMI", "acquirerLabel": "Payarc", "nmiUsername": "YourGatewayLogin",
"detail": {
"orderReference": "ORD-77431",
"billingName": "Dana Whitfield",
"billingEmail": "dana@example.com",
"billingPhone": "+14155550142",
"billingLine1": "1200 Harrison St",
"billingCity": "San Francisco",
"billingState": "CA",
"billingPostal": "94103",
"billingCountry": "US",
"authCode": "A7X21B",
"responseCode": "100",
"responseMessage": "Refund approved",
"avsResult": "Y",
"cardExpMonth": 11,
"cardExpYear": 2029,
"arn": "74567890123456789012345",
"settlementBatchId": "BATCH-5512"
}
}}
}
```
**What it holds, and how reliably.** These are fill rates measured across the stored
transaction rows. They say how often *we hold a value*, not whether the block is on the
delivery, and they are not a guarantee for any one transaction:
| Field | On a charge | On a refund | |
|---|---|---|---|
| `orderReference` | ~100% | ~100% | your own key for the order |
| `metadata` | when you sent it | issued here only | your `metadata` from `POST /v1/charges`, or from the payment intent the charge was made from |
| `responseCode` / `responseMessage` | ~99% | ~99% | what the gateway said |
| `cardExpMonth` / `cardExpYear` | ~99% | ~98% | expiry; brand/last4 ride `instrument` |
| `billingName` / `billingEmail` | ~99% | ~95% | the buyer as you supplied them |
| `billingLine1` / `City` / `Country` | ~99% | ~95% | |
| `billingPhone` | ~87% | ~72% | |
| `authCode` | ~59% | ~87% | approvals only |
| `avsResult` / `cvvResult` | ~94% / ~28% | ~86% / not sent | address and CVV verdicts |
| `arn` | not sent | ~27% | refunds only, see below |
| `settlementBatchId` | not sent | ~43% | refunds only |
> ⚠️ **`arn` and `settlementBatchId` are on the REFUND events only.** They are not carried on
> `charge.*` at all, because a charge is emitted at authorisation, before either value exists.
> For a charge's settlement trail, re-read the transaction or subscribe to
> `transaction.enriched`, the event that fires when clearing data lands.
> ⚠️ **Sparse is not missing.** An `arn` is minted by the card network at clearing, days
> after the refund, so an absent one means *not yet*, never *never*. The same event is
> not re-sent when it arrives; re-read the resource, or use `transaction.enriched`.
> ⚠️ **Empty and zero fields are omitted, not nulled**. The same rule as the rest of the
> payload. Read every field defensively (`detail?.authCode ?? ""`,
> `Number(detail?.cardExpMonth ?? 0)`).
**Two values that are deliberately absent.**
- **`refundReason` is not populated.** It exists in the schema, but refunds made at the
gateway carry no reason from the processor, and that is nearly all of them. Rather than
send you a field that is always empty, we leave it unset, do not build on it.
- **3-D Secure and `paymentMethodType` are on charge events, not refund events.** A
refund is not re-authorised, so it has no SCA outcome of its own. Read them from the
`charge.*` event for the original sale.
- **`walletType` rides beside them**: `"apple_pay"` or `"google_pay"` when the charge was
an Apple Pay / Google Pay payment we processed, omitted otherwise. An approved live sale
synced from your gateway is tagged only once the gateway has named its wallet, usually a
few minutes after the sale, so its events omit it, and no event is sent when the wallet is
learned: read it on the transaction (`card.wallet` on `GET /v1/charges/{id}`). The same
holds for a sale synced before this existed, tagged when a one-off history check reaches
it: old sales never re-notify you.
On such a charge `instrument.last4` is the device token's, not the buyer's physical card; the physical
card's last four and network ride as `walletCardLast4` / `walletCardBrand` when the
wallet sheet stated them (omitted otherwise). All three are on the event raised when the
charge is decided (`charge.succeeded` on an automatic capture, `charge.authorized`,
`charge.failed`) and on a later manual capture's `charge.succeeded`, and a refund issued
here (API or dashboard) repeats them from the original sale. A later capture carries no 3-D Secure
block, so read that from the charge itself (`GET /v1/charges/{id}`).
**Verdicts are the published short form.** `avsResult` / `cvvResult` read `"MATCH"`,
`"NO_MATCH"`. The same values the REST API returns, never an internal enum name.
Gateway-sync transactions may instead carry the raw issuer letter (`"Y"`, `"N"`, `"U"`),
passed through as the processor sent it.
---
### The subscription block
`data.object.detail` on every `subscription.*` event except `usage_exceeded`, which is a
metering signal and carries none. The flat fields above it (`subscriptionId`, `planId`,
`status`, `quantity`) stay; this adds the rest of the record.
```json
"detail": {
"planId": "plan_pro", "status": "PAST_DUE", "quantity": 3, "currency": "USD",
"amount": { "minorUnits": "8997", "currencyCode": "USD" },
"interval": "MONTH", "intervalCount": 1,
"currentPeriodStart": "2026-08-01T00:00:00Z",
"currentPeriodEnd": "2026-09-01T00:00:00Z",
"billingTimezone": "America/New_York",
"trialEnd": "2026-08-15T00:00:00Z",
"cancelAtPeriodEnd": true,
"cancellationReason": "too expensive",
"collectionMethod": "CHARGE_AUTOMATICALLY",
"dunningState": "NONE",
"collectTax": true, "couponId": "cpn_10", "currentUsage": 42
}
```
**The one field worth building on.** `cancelAtPeriodEnd`, on `subscription.canceled`,
is the difference between *revoke access now* and *revoke access on the period
boundary*. Getting it wrong either cuts a paying customer off early or gives away free
time. Everything else on the block is descriptive; read the caveats below before you
branch on any of it.
> **`amount` is the recurring base for the whole subscription**. The plan's price
> multiplied by `quantity`. Three seats of a $29.99 plan read `"8997"`, not `"2999"`. It
> is **not** the settled charge: coupon discounts and downgrade credits are subtracted and
> tax is added when the renewal is charged, so the amount your customer is billed can
> differ. Read `amount` as "what this agreement bills before adjustments".
> **`amount`, `interval` and `intervalCount` ride the same priced line, so all three
> arrive or vanish together.** They are on all eight events that carry `detail`,
> `created`, `updated`, `renewed`, `past_due`, `canceled`, `paused`, `resumed` and
> `suspended`. Five of those shipped none of the three until recently, so an older
> integration treating them as renewal-only fields was reading a real gap. They still drop
> out together when no priced line is stored against the subscription: an absent `amount`
> means *not stated on this event*, never *this subscription bills nothing*, and the plan
> stays the price of record.
> ⚠️ **`dunningState` is always `"NONE"`, and that does not mean nobody is retrying.**
> Billing runs no dunning clock of its own, decline recovery is owned by the payments
> engine, which does retry a failed renewal and reports it on the
> `decline_recovery.recovered` / `.failed` / `.hard_declined` events. So the trio is inert
> here: `dunningState` ships the literal `"NONE"`, and `dunningAttempts` is always a zero
> the payload omits. `nextDunningAt` is absent too, with one exception. A retry you
> trigger yourself from the dashboard stamps it with the moment that attempt was
> *claimed*, so where it does appear it is a past timestamp of an attempt already made,
> never a future one we have scheduled. Subscribe to `decline_recovery.*` if you need to
> know whether we are still trying, and do not branch on the dunning trio. `"RETRYING"`
> is not one of this field's values and never was. The declared set is `NONE`, `ACTIVE`,
> `FIRST_NOTICE`, `SECOND_NOTICE`, `FINAL_NOTICE`, `SUSPENDED`.
> ⚠️ **These fields exist in the schema and are never populated:** `nextBillingAt`,
> `endDate`, `externalId`, `invoiceDescription` and `taxRateId`. No write path in the billing
> service sets any of them, so they are absent from **every** delivery. They are listed
> here rather than quietly dropped so you can recognise them if you meet them in a
> schema; do not build on them. For the next charge date, read `currentPeriodEnd`.
> **No `checkoutUrl`, no `defaultPaymentMethodId`, no `metadata`.** The first is an
> operational handle that expires, the second is our tokenisation detail, and the third
> is already yours: `GET /v1/subscriptions/{id}` returns it.
### The invoice block
`data.object.detail` on every `invoice.*` event.
```json
"detail": {
"invoiceNumber": "INV-2026-AB12CD-000001", "status": "OPEN",
"title": "August services",
"subtotal": { "minorUnits": "13000", "currencyCode": "USD" },
"discountAmount": { "minorUnits": "500", "currencyCode": "USD" },
"taxAmount": { "minorUnits": "1320", "currencyCode": "USD" },
"shippingAmount": { "minorUnits": "1000", "currencyCode": "USD" },
"total": { "minorUnits": "14820", "currencyCode": "USD" },
"amountPaid": { "minorUnits": "10000", "currencyCode": "USD" },
"amountRemaining": { "minorUnits": "4820", "currencyCode": "USD" },
"dueDate": "2026-08-15T00:00:00Z", "finalizedAt": "2026-08-01T00:00:00Z",
"hostedInvoiceUrl": "https://pay.example/invoices/inv_1/view",
"collectionMethod": "SEND_INVOICE", "autoAdvance": true,
"lineCount": 2,
"lines": [
{ "description": "Pro plan, 3 seats", "quantity": 3,
"unitPrice": { "minorUnits": "3000", "currencyCode": "USD" },
"taxAmount": { "minorUnits": "900", "currencyCode": "USD" },
"total": { "minorUnits": "9400", "currencyCode": "USD" },
"productId": "prod_pro" }
]
}
```
- **`amountRemaining` is the field to branch on, read it as
`Number(detail.amountRemaining?.minorUnits ?? 0)`.** A *partially paid* invoice is not a
paid one, and `status` alone does not tell you which you have. The `?? 0` is mandatory,
not stylistic: on a fully paid invoice the remaining balance is zero, and a zero money
has no `minorUnits` key (see below). `Number(undefined) === NaN`, and `NaN === 0` is
`false`, so the naive read reports a paid invoice as still owing, on **every**
`invoice.paid` / `invoice.payment_succeeded` delivery.
- **On `invoice.voided`, check `amountPaid`.** A void after a partial payment leaves
money that has to be refunded or credited.
- **`lineCount` is the true total; `lines` stops at 200.** On the rare invoice longer
than that, `lines.length` is less than `lineCount` and the resource is the authority.
> **Every money value is an object, never a bare number**, so cents can never be read as
> dollars. When there is an amount it is `{ "minorUnits": "4820", "currencyCode": "USD"
> }`, `minorUnits` is a JSON *string*, because it is a 64-bit integer. ⚠️ **A zero money
> arrives as `{ "currencyCode": "USD" }` with no `minorUnits` key.** Zero is the default
> for an integer, and the payload omits defaults, so the key is gone rather than `"0"`.
> Always read money as `Number(m?.minorUnits ?? 0)`. This is not an edge case in this
> block. `amountRemaining` is zero on every paid invoice; `taxAmount` is zero on an
> invoice with no tax; `discountAmount` is zero on an undiscounted one; `amountPaid` is
> zero on `invoice.created` and `.finalized`; a line's `taxAmount` is zero on an untaxed
> line. Every one of those arrives with `currencyCode` alone. ⚠️ **`invoice.overdue` is
> not on that list, and assuming it is costs money.** The overdue sweep picks up any
> invoice that is still `OPEN` with a balance left past its due date, so a *partially
> paid* one is exactly what it fires on. `amountPaid` is a real figure there. The same
> reason the `invoice.voided` bullet above tells you to read it.
> **`lines` is present on every invoice event.** If it is ever absent, that means *not
> sent on this event*, never *nothing was billed*. Read the invoice if you need them.
**Where the payable link is.** `invoice.sent` carries two links at the **top level**
of `data.object`, beside `detail` rather than inside it, and both arrive complete:
| Field | What it opens |
|---|---|
| `hostedInvoiceUrl` | the hosted invoice page, view, download, print, and pay |
| `checkoutUrl` | the checkout session that takes the payment |
These are **bearer links**, deliberately. They are the same links our own invoice email
puts in front of your customer, and they work for whoever holds them. That is what makes
an invoice payable without a login. Send them on; that is what they are for.
> ⛔ **Therefore treat an `invoice.sent` body, and anything you log it into, as secret
> material.** Anyone who can read that delivery (or your access logs, or a screenshot in a
> support ticket) can pay, or view, that invoice. Store the links where you would store a
> payment link, not where you would store an event id, and keep them out of analytics
> pipelines and error trackers.
> **Inside `detail` it is different, and the two fields differ from each other.** On every
> `invoice.*` event, `invoice.sent` included, `detail.hostedInvoiceUrl` is the invoice
> page **with its `?token=` removed** (a stable reference you can key on, not a way in)
> and `detail.checkoutUrl` is **always the empty string**. The checkout link is
> `…/checkout/` and that token *is* the payment capability, carried as a
> path segment, so there is no token-free form of it to publish: the builder drops the
> whole URL rather than try to filter it. On `invoice.sent` that costs you nothing,
> because the payable pair is already at the top level. On every other invoice event it is
> the difference between a reference and a credential. If you need a payable link outside
> `invoice.sent`, re-send the invoice from the dashboard; there is no public REST endpoint
> that returns one.
## Retries and auto-disable
A failed delivery (non-`2xx`, timeout, or connection error) is retried on a fixed backoff
ladder, one attempt per rung:
| Retry | Delay after previous attempt |
|---|---|
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 15 minutes |
| 4th | 1 hour |
| 5th | 3 hours |
| 6th | 6 hours |
| 7th | 12 hours |
| 8th | 12 hours |
> **Retries stop after the 8th.** There are **9 attempts in total**, spanning roughly **34
> hours** from the first failure. After that the delivery is marked permanently failed and
> is never retried again. An outage longer than that window drops those events for good
> (see [reconciling](#reconciling-without-webhooks).)
The retry budget belongs to each event, not to the endpoint: every event gets the same 9
attempts, whenever the endpoint was created.
### When an endpoint keeps failing
A failing endpoint goes through two stages. Both end at the first `2xx`.
**Throttled, after 10 failures in a row.** We stop sending it every event. It receives
**at most one automatic request a minute**, and the other events are kept for it (see
below). Nothing else changes: the endpoint stays enabled and nobody is emailed. This is
what stops a down or slow endpoint from holding up your other endpoints.
**Auto-disabled, after 24 hours.** An endpoint is auto-disabled only when its failures
have been continuous for a sustained period: **every delivery to it has failed for at
least 24 hours, and at least 10 deliveries in a row have failed.** Both must hold. A
burst of failures does not disable it, however many there are (a bad deploy that returns
`500` for ten minutes, or a signature check that rejects everything until you fix it).
When we disable an endpoint we email your account owner and add a notification to your
dashboard. The dashboard shows it with an "Auto-disabled" badge, and its delivery log
shows the responses that led there. Disabling does not delete the endpoint or its
configuration.
> **While it is disabled, your URL receives at most one automatic request an hour.** When
> an hour has passed, the next event that comes due for the endpoint is sent as a trial
> delivery (a *probe*); if no event comes due, there is no probe until one does. If your
> server returns `2xx`, the endpoint is switched back on and the failure count resets. If
> it does not, the endpoint stays disabled and the next probe is at least an hour later.
> So a server that recovers on its own (a deploy that finishes, a certificate that gets
> renewed) needs no action from you, and one that stays down is not flooded. You can
> re-enable it at once from Dashboard → Developers → Webhooks → the endpoint's toggle.
> **Retry** and **Send test** in the dashboard are not automatic: they send immediately,
> whatever state the endpoint is in, and a `2xx` from either switches it back on too.
> **Events that occur while it is throttled or disabled are kept, not dropped.** Each one
> is queued for the endpoint and keeps the retry schedule above. Once the endpoint
> succeeds again, each queued event is sent on its next retry. Expect them over the
> following hours, not all at once and not in order, so dedupe on `X-Webhook-Id`. In the
> endpoint's delivery log a queued event shows as `PENDING` with no response, or as
> `FAILED` with its last response if it had already been tried before the endpoint was
> held back.
>
> **The queue is bounded by each event's own retry window.** When an event's 9 attempts
> run out while the endpoint is still failing (about 34 hours after the event), its queued
> delivery becomes `DEAD_LETTER`: it is not pushed any more, it stays in
> [`GET /v1/events`](/developers/19-events.md), and **Retry** in the delivery log still sends it.
>
> **An endpoint that has been disabled for longer than that stops queueing.** From then
> on a new event is not queued for it at all: its delivery is recorded as `DEAD_LETTER`
> straight away, with nothing sent, and **Retry** still sends it. The event is in
> `GET /v1/events` for 7 days. The hourly probe goes on, so the endpoint can still recover
> on its own. Anything you need from that period is what
> [reconciling](#reconciling-without-webhooks) is for.
An endpoint **you** switch off is different: we never probe it, never queue events for it
and never switch it back on. That includes switching off an endpoint we had
auto-disabled: from then on it is yours, and stays off until you turn it on. Deliveries
still queued for it at that moment become `DEAD_LETTER` (you can still **Retry** one).
Deleting an endpoint marks its queued deliveries `DEAD_LETTER` too.
Because the retry window is finite, [reconciling](#reconciling-without-webhooks) is not an
optional safety net. It is how you recover anything that fell outside it. Treat webhook
health as something you actively monitor: if you stop seeing deliveries for an event you
subscribe to, check the dashboard rather than assuming nothing happened.
### Deliveries that were recorded but never sent
When we re-import historical data (a backfill, or a re-sync after a provider outage), the
events it produces are recorded for your endpoint but **deliberately not sent**, so a
re-import cannot re-notify you about things you were told about weeks ago. In the delivery
log such a row is labelled `SUPPRESSED`. Underneath, it is stored as delivered with no
response status and carries a `replay` flag, so read the flag before the status: a
suppressed row is not a request your server received, and it cannot be retried.
---
## Verifying a delivery
Each delivery is signed. Verify the signature before trusting the payload. That proves
it came from WorthyPayments and wasn't tampered with. Use the signing secret from the
dashboard endpoint configuration. Reject any request whose signature doesn't match.
Every delivery carries these headers:
| Header | Value |
|---|---|
| `X-Webhook-Signature` | `v1=`. HMAC-SHA256, hex digest, prefixed with the literal `v1=`. After `POST /webhook-endpoints/{id}/roll-secret`, both the new and previous secrets are valid for **24 hours** and the header is `v1=,v1=`. Verify by testing each `v1=` part, do not compare the whole header string. |
| `X-Webhook-Timestamp` | Unix seconds at send time. Re-generated on every retry attempt, so it is **not** the envelope's `created` field. |
| `X-Webhook-Id` | The **occurrence** id. The same value as `id` in the body. Stable across every retry, and identical on every endpoint you have subscribed to that event. Use it to dedupe. |
| `X-Webhook-Mode` | `live` or `test`: the mode of the endpoint that signed this delivery. Use it to pick the secret before you read the body. |
| `X-Webhook-Endpoint-Id` | The id of the endpoint that signed this delivery (the `id` returned when you created it). If you keep one secret per endpoint id, look the secret up by this. |
> ⚠️ **`X-Webhook-Id` is not a handle you can fetch.** It identifies the occurrence, and
> `GET /v1/events/{id}` is keyed on a **delivery** (one row per subscribed endpoint) so
> passing the header value there answers `404`. On the polled feed, this header's value is
> the item's `event_id`, not its `id`. See [Events → Two ids](/developers/19-events.md#list-events).
> ⚠️ **One signing secret per endpoint, and test mode and live mode are separate
> endpoints.** An endpoint belongs to one mode. Register the same URL in test mode and in
> live mode and you have two endpoints with two different secrets; a delivery is signed
> with the secret of the endpoint it was sent to, and nothing else. So if one URL serves
> both modes, choose the secret by the `X-Webhook-Mode` header (`live` → your live
> endpoint's secret, `test` → your test one; the envelope's `livemode` says the same), or by
> `X-Webhook-Endpoint-Id`, or try each endpoint's secret in turn. Trust nothing in the body
> until the signature verifies.
```js
// One URL, both modes: pick the secret by the header, then verify.
const secret = req.get("X-Webhook-Mode") === "live"
? process.env.WP_WEBHOOK_SECRET_LIVE
: process.env.WP_WEBHOOK_SECRET_TEST;
```
> Checking a live delivery against the test secret rejects every
> delivery, which looks exactly like a broken integration, and an endpoint that rejects
> every delivery for a day is [auto-disabled](#when-an-endpoint-is-auto-disabled).
The signed string is `` `${timestamp}.${rawBody}` ``: the timestamp from
`X-Webhook-Timestamp`, a literal `.`, then the exact raw request body.
```js
import crypto from "crypto";
function verify(rawBody, headers, secret) {
const timestamp = headers["x-webhook-timestamp"];
const signatureHeader = headers["x-webhook-signature"];
if (!timestamp || !signatureHeader) return false;
const digest = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const expected = Buffer.from(`v1=${digest}`);
// Roll-secret overlap sends `v1=,v1=`. Compare each part.
// Length MUST match before timingSafeEqual — it throws a RangeError on
// unequal-length buffers, which would 500 your handler instead of rejecting.
for (const part of String(signatureHeader).split(",")) {
const got = Buffer.from(part.trim());
if (got.length !== expected.length) continue;
if (crypto.timingSafeEqual(expected, got)) return true;
}
return false;
}
```
Verify against the **raw request body**. Don't parse the JSON first, or whitespace
differences will break the signature.
Also reject deliveries whose `X-Webhook-Timestamp` is far from your own clock (a few
minutes of tolerance is typical) so a captured delivery cannot be replayed later.
---
## Responding
Return a `2xx` quickly to acknowledge receipt. Do any heavy work asynchronously. If you
block, the delivery may time out and be retried, causing duplicate processing. Make your
handler **idempotent**: dedupe on the charge `id` so a retried delivery doesn't double-fulfill
an order.
---
## Reconciling without webhooks
Webhooks are the push model. If you miss one (endpoint down for a window), reconcile by
polling [`GET /charges`](/developers/03-charges.md), walking the pages as described in
[Pagination](/developers/13-pagination.md). Every succeeded charge is there regardless of webhook
delivery.
There is **no server-side date filter**. `GET /charges` accepts `limit`, `starting_after`
and `status` only; any other query parameter is ignored, and you get an unfiltered page
back rather than an error. So reconcile by walking the cursor and stopping yourself:
```js
// Walk newest-first until you reach a charge you have already recorded.
async function reconcile(lastSeenChargeId) {
const recovered = [];
let cursor;
for (;;) {
const qs = new URLSearchParams({ limit: "100", status: "succeeded" });
if (cursor) qs.set("starting_after", cursor);
const page = await api(`/v1/charges?${qs}`);
for (const charge of page.data) {
if (charge.id === lastSeenChargeId) return recovered;
recovered.push(charge);
}
if (!page.has_more) return recovered;
cursor = page.data[page.data.length - 1].id;
}
}
```
Persist the id of the newest charge you have processed and pass it as `lastSeenChargeId`
on the next run. `GET /events` (`events:read`) lists recent deliveries the same way, and
also takes `limit` and `starting_after` only.
### Pre-dispute alerts: poll by timestamp instead
Alerts are the one resource with a real server-side cursor over *change* rather than
creation, because an alert's whole point is that its state moves, matched, then refunded.
`GET /v1/pre_dispute_alerts` takes `updated_since`, so you do not have to walk from the
top and stop yourself.
First, the `api()` helper the examples below use. Nothing exotic. One `fetch` wrapper that
attaches the key and turns a non-2xx into a throw:
```js
const BASE = "https://api.worthypayments.com";
async function api(path) {
const res = await fetch(`${BASE}${path}`, {
headers: { "x-api-key": process.env.WP_SECRET_KEY },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// 401 = bad key, 403 = key is missing the `disputes:read` scope,
// 429 = slow down and honour Retry-After.
throw new Error(`${res.status} ${body.error?.code ?? "request_failed"}`);
}
return res.json();
}
```
Then the paging loop:
```js
// Persist `highWaterMark` between runs. Start it at 0 for a first full sync.
async function pollAlerts(highWaterMark) {
let cursor;
let newest = highWaterMark;
for (;;) {
const qs = new URLSearchParams({ limit: "100" });
if (highWaterMark) qs.set("updated_since", String(highWaterMark));
if (cursor) qs.set("starting_after", cursor);
const page = await api(`/v1/pre_dispute_alerts?${qs}`);
for (const alert of page.data) {
handle(alert); // must be idempotent — see below
newest = Math.max(newest, alert.updated_at);
}
if (!page.has_more) return newest; // caught up; sleep until next run
cursor = page.next_cursor;
}
}
```
The polled object is a **superset** of the webhook payload, same lifecycle fields, plus
the match evidence (`arn`, `auth_code`, `card_bin`, `card_last4`, `order_id`), the
sub-merchant MID, the processor, and the linked sale's gateway id. Reconcile push against
pull on `id`.
`auto_rule` is worth reading even when you only care about refunds. On an `AUTO_REFUNDED`
alert it names the rule that moved the money and when it was evaluated; when nothing fired
it carries `decision: "NONE"` and a `reason`, so an alert sitting open is explained rather
than mysterious, `sale-already-refunded` means the platform found the sale already
refunded and refused, `operator-excluded` means a person marked it hands-off.
Two properties worth knowing, both consequences of ordering by `updated_at` ascending:
- **You cannot miss an alert.** One that changes while you are paging moves to the *end* of
the sequence rather than shifting the rows ahead of it, so it lands on a later page.
- **You can see one twice**, for exactly that reason. Dedupe on `(id, updated_at)`. The
same at-least-once handling the webhooks already require of you.
Do not pass an `updated_since` you are unsure of: a value that is neither unix seconds nor
RFC3339 is rejected with `400 parameter_invalid` rather than ignored, because silently
treating a typo as "from the beginning" would re-deliver your entire alert history.
The endpoint needs the `disputes:read` scope, which new keys get by default. An older key
may predate that, add it under Dashboard → **Developers** if a call answers 403. `GET
/v1/pre_dispute_alerts/{id}` fetches one alert, and accepts either our id or the network's
own (`ETH-9821`, `CDRN-4408`, `RDR-2841`).
### Pre-dispute alert payloads
`pre_dispute_alert.created` and `.updated` carry the **same object** `GET
/v1/pre_dispute_alerts` returns, under `data.object.alert`, same producer, same mapper, so
a pushed alert and a polled one never disagree.
```json
{
"id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"object": "event",
"type": "pre_dispute_alert.created",
"livemode": true,
"data": {
"object": {
"alertId": "f206cc55-9266-49fe-8663-74879c4589cf",
"transactionId": "TXN-3729a67d-...",
"source": "ETHOCA",
"slaDueAt": "2026-08-20T15:48:32Z",
"occurredAt": "2026-08-19T15:48:41.049Z",
"alert": {
"id": "f206cc55-...",
"externalId": "ETH-9821",
"amount": { "minorUnits": "13550", "currencyCode": "USD" },
"reason": "FRAUDULENT",
"state": "NEW",
"priority": "HIGH",
"slaStatus": "OK",
"customerId": "ae7babf2-8c52-43fe-b2ae-c3395b17add8",
"matchEvidence": {
"arn": "74599000111222333444555",
"authCode": "B7C4D1",
"cardBin": "424242",
"cardLast4": "4242",
"orderId": "ord_9400",
"rung": "auth",
"strength": "strong"
}
}
}
}
}
```
> **Read `alert`, not the flat fields.** The keys beside it, `alertId`, `transactionId`,
> `source`, `slaDueAt`, are the original payload and stay for subscribers written against
> it, but they cannot tell you which charge the alert names in *your* system.
> `alert.matchEvidence` carries the ARN and auth code you already hold, which is what
> makes the alert reconcilable on first delivery rather than after a follow-up API call.
> Its card BIN/last4 are the card the *issuer* reported, and they can differ from the card
> you hold for the sale without the link being wrong: Apple Pay and Google Pay charge a
> device token rather than the card, and virtual and reissued cards do the same. Reconcile
> on `transactionId`, the ARN or the auth code, never on the card digits.
> `alert` is absent only when the emitting path had no row to shape,
> treat a missing `alert` as "fall back to the flat fields", never as an error.
### Dispute payloads
`dispute.created`, `dispute.won`, `dispute.lost` and `dispute.closed` carry the **same
`dispute` object** `GET /v1/disputes` returns, under `data.object.dispute`, same producer,
same mapper, so a pushed dispute and a polled one never disagree. That object is where
`nmi_transaction_id`, `source_acquirer` (Payarc / Nuvei / Maverick Payments), the card
BIN/last4, `respond_by` and `reason_message` live.
`dispute.updated` and `dispute.funds_withdrawn` carry it too. The object is declared on
every `dispute.*` event and is filled from the row inside the transaction that wrote it.
⚠️ **`dispute.funds_reinstated` is the one name where `data.object.dispute` is optional,
read it as neither guaranteed nor absent.** Six paths in our dispute service return held
funds and each raises this event. One of them fills the nested object; the other five send
the flat fields alone. Four of those five raise `dispute.won` in the same transaction
*with* the object, so the cheapest fallback is to take the card and the gateway id off
that event. The fifth is a pre-dispute alert freeing the reserve, and it raises
`dispute.won` only when the alert is refunded. A dismissed one raises no `dispute.won`, so
keep `GET /v1/disputes/{disputeId}` as the last resort. Write the handler as
`data.object.dispute ?? await fetchDispute(...)`: "always" and "never" are both wrong on
this name, and a handler built for either one is what breaks.
⚠️ **`nmi_transaction_id` is best-effort.** It comes from joining the linked sale, so an
unmatched dispute (or one whose sale has not synced yet) carries an empty value.
The flat fields beside it differ per event and stay for subscribers written against them,
but they cannot tell you which charge the dispute names in *your* system. Read `dispute`
first.
`dispute.created` is the widest. It fires once per dispute, for every stage and provider:
```json
{
"id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"object": "event",
"api_version": "2026-01-01",
"created": 1787052300,
"livemode": true,
"type": "dispute.created",
"data": {
"object": {
"disputeId": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
"transactionId": "9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55",
"merchantAccountId": "cmt3yfavu00013iokngc9rjtw",
"envMode": "ENV_MODE_LIVE",
"amount": { "minorUnits": "13550", "currencyCode": "USD" },
"reasonCode": "10.4",
"status": "NEEDS_RESPONSE",
"provider": "Payarc",
"occurredAt": "2026-08-18T10:15:00Z",
"customerRef": "ae7babf2-8c52-43fe-b2ae-c3395b17add8",
"dispute": {
"id": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
"nmiTransactionId": "12437218480",
"sourceAcquirer": "Payarc",
"cardBrand": "visa",
"cardBin": "424242",
"cardLastFour": "4242",
"cardholderName": "A. LOVELACE",
"status": "DISPUTE_STATUS_NEEDS_RESPONSE",
"stage": "DISPUTE_STAGE_INQUIRY",
"reasonCode": "10.4",
"reasonMessage": "Cardholder does not recognise the charge",
"amount": { "minorUnits": "13550", "currencyCode": "USD" },
"respondBy": "2026-08-25T00:00:00Z"
}
}
}
}
```
> `dispute` is optional on `dispute.funds_reinstated`. One of the six paths that return
> held funds fills it, five do not (see above), and on the other names it is missing only
> when the emitting path could not read the row. Treat a missing `dispute` as "fall back
> to the flat fields", never as an error. `dispute.won` and `dispute.lost` carry it, which
> is what makes a resolution actionable: they used to say only *that* you won or lost,
> never on which card or why.
> ⚠️ **Enum values inside `dispute` are prefixed; the flat fields beside it are not.** The
> nested object is generated from the protobuf `Dispute` message, where `status`, `stage`,
> `reason`, `outcome`, `escalationLevel` and `partnerProvider` are enums, so the wire
> carries `"DISPUTE_STATUS_NEEDS_RESPONSE"`, not `"NEEDS_RESPONSE"`. The flat `status` at
> the top of the payload is a plain string and stays unprefixed. Match on the prefixed
> form when you read `dispute.status`, or strip the `DISPUTE_STATUS_` / `DISPUTE_STAGE_`
> prefix yourself. Timestamps inside `dispute` (`respondBy`, `dueDate`, `disputeDate`,
> `transactionDate`) are RFC3339 strings, while `created` on the envelope is unix seconds.
> `pre_dispute_alert.*` differs here: its nested `alert` declares `state`, `priority`,
> `reason` and `slaStatus` as plain strings, so those arrive unprefixed. The two objects
> are not interchangeable on this point.
What each event carries. Every key camelCase, and omitted entirely when the producer had
no value for it:
| Event | Fields beyond the common set |
|---|---|
| `dispute.created` | `reasonCode`, `status`, `provider`, `customerRef` |
| `dispute.updated` | `reasonCode`, `status`, `provider`. Fires on evidence submission, and on a [link change](#link-changes), which adds `customerRef`, `previousTransactionId`, `linkChange` and `linkChangeReason` |
| `dispute.won` | `customerRef` |
| `dispute.lost` | `customerRef`, **`fee`** (a Money object. The dispute fee you were charged) |
| `dispute.funds_withdrawn` | `customerRef` |
| `dispute.funds_reinstated` | `customerRef` |
The common set is `disputeId`, `transactionId`, `merchantAccountId`, `envMode`, `amount`
and `occurredAt`.
Four things that bite:
- **`disputeId`, not `id`.** Reading `data.object.id` returns `undefined`. That value is
the same id `GET /v1/disputes/{id}` takes.
- **`dispute.won` and `dispute.lost` each also fire `dispute.closed`.** One decision
delivers twice if you subscribe to both. They are separate deliveries with separate
event ids, so dedupe on `disputeId` + your own state, not on the event id alone.
- **`dispute.created` also fires for pre-dispute alerts** (Ethoca / CDRN / RDR), which are
warnings, not chargebacks. If you only want formal disputes, subscribe to
`pre_dispute_alert.created` as well and branch, or reconcile against `GET /v1/disputes`.
An alert-sourced row carries `kount_alert_id` there.
- **`stage` is in the nested `dispute` object**, never among the flat fields, read
`data.object.dispute.stage` to tell an inquiry from a chargeback, no round-trip needed.
It is there on `dispute.created`, `.won`, `.lost`, `.closed`, `.updated` and
`.funds_withdrawn`. On `dispute.funds_reinstated` it rides only the shared
terminal-resolution lane; on the other five paths that return held funds it is absent,
so that one needs the `dispute.won` that usually accompanies it, or `GET
/v1/disputes/{disputeId}`.
Because the event is thinner than the REST object, the common pattern is to treat the
webhook as a *hint* and fetch the full record:
```js
app.post("/webhooks/wp", async (req, res) => {
res.sendStatus(200); // ack first, work after
const e = req.body;
if (!e.type.startsWith("dispute.")) return;
const id = e.data.object.disputeId;
const dispute = await api(`/v1/disputes/${id}`); // full object, REST field names
await upsertDispute(dispute); // idempotent on dispute.id
});
```
### Link changes: a case left the sale it named
When we remove a pre-dispute alert's or a dispute's link to a sale, move it to another
sale, or give a dispute we delivered without a sale its sale after review, you receive the
update event you already subscribe to: `pre_dispute_alert.updated` for an alert,
`dispute.updated` for a dispute. Three keys are added to `data.object`, on
that update only:
| Key | Meaning |
|---|---|
| `previousTransactionId` | The sale the case was linked to before this change (absent on `LINKED`) |
| `linkChange` | `UNLINKED`: the case names no sale now. `RELINKED`: it moved to the sale in `transactionId`. `LINKED`: a dispute you received without a sale now names the sale in `transactionId` |
| `linkChangeReason` | Why the link changed (see below) |
`transactionId` keeps its meaning: the sale linked **now**. On an `UNLINKED` update the key
is absent, so read the sale that was cleared from `previousTransactionId`. A `LINKED` update
has no `previousTransactionId`: the dispute had no sale before. It follows a review that
matched a chargeback we first delivered unmatched, for example one whose card digits are the
card network's token for the card rather than the card itself.
| `linkChangeReason` | What happened |
|---|---|
| `EVIDENCE_CONFLICT` | The network's authorization code or card digits contradict the sale |
| `DECLINED_ATTEMPT` | The sale was a declined, voided or otherwise unsuccessful attempt, not a completed sale |
| `NOT_A_SALE` | The sale was a refund, not a completed sale. On `RELINKED` the case moved to the sale that refund reversed |
| `ARN_COLLISION` | The acquirer reference matched more than one sale, so a link made on it alone was withdrawn or moved |
| `OPERATOR` | Our operations team corrected the link after review |
| `KOUNT_TWIN_SYNC` | The alert and the dispute raised from it were put back on the same sale |
| `REMATCH` | Matching proved a different sale |
**What to do with it.** If you acted on the old link, for example flagged or blocked the
customer behind `previousTransactionId`, undo that for this case. On `RELINKED`, apply it
to the sale in `transactionId` instead. On `LINKED`, treat it as the dispute's sale from now
on, as you would a dispute that arrived matched.
```json
{
"type": "pre_dispute_alert.updated",
"data": {
"object": {
"alertId": "f206cc55-9266-49fe-8663-74879c4589cf",
"externalId": "ETH-9821",
"outcome": "NEW",
"previousTransactionId": "TXN-3729a67d-...",
"linkChange": "UNLINKED",
"linkChangeReason": "EVIDENCE_CONFLICT",
"alert": { "id": "f206cc55-...", "state": "NEW" }
}
}
}
```
Four things to know:
- **Branch on `linkChange`.** Every other update carries none of the three keys. A first
link (an alert matched for the first time) is not a link change either: it arrives as
`outcome: "MATCHED"` without them.
- **`outcome` on an alert.** An open alert reads `MATCHED` after a move and `NEW` after an
unlink, never `MATCHED` without a sale. A resolved alert keeps its resolution
(`REFUNDED`, `DISMISSED`, ...): the refund or the dismissal happened, whatever the link
says now. A link change moves no money, so it carries no `refundStatus`, except while an
automatic refund is in flight: that alert reads `AUTO_REFUNDED` with `refundStatus:
"REQUESTED"`, as the refund rule already announced it. A refund our team requested that the
gateway has not confirmed yet reads as an open alert (`MATCHED` or `NEW`).
- **Once per change.** Each move is delivered once, even if our correction is re-run, and a
case that really moves back and forth is announced every time it moves.
- **History replays stay silent.** A change made while we re-import historical data is
recorded but not delivered. Corrections are delivered as they happen.
### Disputes: the same poll
Formal chargebacks / inquiries use the same cursor on `GET /v1/disputes`. Subscribe to
`dispute.created` / `.updated` / `.won` / `.lost` / `.closed` if you want push; poll is the
catch-up path, and the one that still works when a webhook is silent.
```js
async function pollDisputes(highWaterMark) {
let cursor;
let newest = highWaterMark;
for (;;) {
const qs = new URLSearchParams({ limit: "100" });
if (highWaterMark) qs.set("updated_since", String(highWaterMark));
if (cursor) qs.set("starting_after", cursor);
const page = await api(`/v1/disputes?${qs}`);
for (const dispute of page.data) {
handle(dispute);
newest = Math.max(newest, dispute.updated_at);
}
if (!page.has_more) return newest;
cursor = page.next_cursor;
}
}
```
A page item is the full `dispute` object, REST field names (snake_case), `amount` as an
integer of cents, `currency` lowercase, timestamps as unix seconds:
```json
{
"id": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
"object": "dispute",
"transaction_id": "9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55",
"nmi_transaction_id": "12437218480",
"billing_name": "Ada Lovelace",
"billing_email": "ada@example.com",
"cardholder_name": "A. LOVELACE",
"card_bin": "424242",
"reason_message": "Cardholder does not recognise the charge",
"alert_source": "ETHOCA",
"transaction_date": 1786966800,
"respond_by": 1787656800,
"closed_at": null,
"status": "NEEDS_RESPONSE",
"stage": "INQUIRY",
"outcome": "",
"reason": "FRAUDULENT",
"reason_code": "10.4",
"amount": 13550,
"currency": "usd",
"source_acquirer": "Payarc",
"processor_dispute_id": "cb_9f2210",
"kount_alert_id": "f34a64b7-b8ac-4d4c-9245-efb5437d7261",
"card_brand": "visa",
"card_last4": "4242",
"dispute_date": 1787052000,
"due_date": 1787656800,
"created_at": 1787052300,
"updated_at": 1787066400
}
```
| Field | Notes |
|---|---|
| `id` | This API's dispute id. What `dispute.created` calls `disputeId`. |
| `transaction_id` / `nmi_transaction_id` | The matched sale, and its gateway id. **Both `""` until the dispute is matched**, alert-sourced rows often arrive unmatched. |
| `billing_name` / `billing_email` | Cardholder name and email on the matched sale, projected the same way. `""` when unmatched, or when the sale itself carries no contact, best-effort, not guaranteed. |
| `cardholder_name` | The name as the **provider** sent it on the dispute. Not the same field as `billing_name`, which comes from the sale. They disagree often enough to be worth reading both. ~15% populated. |
| `card_bin` | First six PAN digits. Populated on effectively every dispute. |
| `reason_message` | The provider's own wording, alongside the normalised `reason` / `reason_code`. ~71% populated. |
| `alert_source` | The pre-dispute network this originated from (`ETHOCA`, `RDR`, `VERIFI`); `""` for an ordinary acquirer chargeback. Distinct from `source_acquirer`, which is who reported it. ~52% populated. |
| `transaction_date` | Unix seconds. When the underlying sale happened, lets you age a dispute without fetching the charge. ~48% populated. |
| `respond_by` | Unix seconds. The provider's stated evidence deadline. `due_date` is ours; this is theirs. ~59% populated. |
| `closed_at` | Unix seconds. When the dispute reached a terminal state. ~35% populated. |
| `status` | `NEEDS_RESPONSE`, `NEEDS_MANUAL_MATCH`, `UNDER_REVIEW`, `WON`, `LOST`, `ACCEPTED`, `EXPIRED`, `PROCESSING`, `BUYER_REVIEW`, `BANK_REVIEW`, `APPEALABLE`, `STALE`, `CLOSED`, `RELATED_CASE`. A `RELATED_CASE` is a PayArc related case (reversal, resolution record, pre-arbitration, credit, retrieval fulfilment): not a dispute of its own, its `parent_dispute_id` carries the family's state. |
| `parent_dispute_id` | Set only when `status` is `RELATED_CASE`: the dispute that carries the family's state. `""` for every other dispute. |
| `stage` | `INQUIRY`, `CHARGEBACK`, `PRE_ARBITRATION`, `ARBITRATION`, `UNCLASSIFIED`. Unprefixed here; the webhook's nested `dispute.stage` carries the same fact as `DISPUTE_STAGE_INQUIRY`. Not among the webhook's flat fields at all. |
| `amount` | Integer **cents** (`13550` = $135.50), unlike the webhook's `amount.minorUnits` string. |
| `outcome` | `""` while open. |
| `source_acquirer` | `Payarc`, `Maverick Payments`, `NMI`, `Nuvei`. |
| `kount_alert_id` | Set when the dispute came from an Ethoca/CDRN alert; joins to `pre_dispute_alert.id`. |
| `due_date` | Evidence deadline, `null` when the provider set none. Watch this, not `dispute_date`. |
| `updated_at` | Your high-water mark. |
Unknown strings are `""` and unknown timestamps are `null`, never missing, so a field
access will not throw. (The webhook payload behaves the opposite way: empty fields are
omitted entirely.)
Filter with `status` and `stage` if you only care about part of the book. An unrecognised
value is **ignored**, not rejected. The filter is dropped and you get the unfiltered list,
so check the spelling if a filtered poll returns more than you expected.
`GET /v1/disputes/{id}` accepts our id, the processor's dispute id, or the Kount request
id (`kount_alert_id`). Same `disputes:read` scope. Dedupe on `(id, updated_at)`. A dispute
belonging to another merchant answers `404`, identical to one that does not exist.
A row's `updated_at` can move without a webhook (matcher sweeps, ingest). Poll is the
source of truth for "what changed"; the webhook is the push hint.
### Running it on a schedule
The whole job, end to end: read the last high-water mark, drain both feeds, store the new
mark. Every 5–10 minutes is a sensible cadence. The data is not real-time, and a tighter
loop mostly spends your rate limit re-asking a question with no new answer.
```js
// poll-job.js — `node poll-job.js`, or call runOnce() from your own scheduler.
import { readFile, writeFile } from "node:fs/promises";
const STATE = "./wp-poll-state.json"; // use your DB in production, not a file
async function loadMarks() {
try {
return JSON.parse(await readFile(STATE, "utf8"));
} catch {
return { alerts: 0, disputes: 0 }; // first run: 0 = full backfill
}
}
export async function runOnce() {
const marks = await loadMarks();
// Each returns the newest updated_at it saw, so a crash mid-run re-reads
// rather than skips. Both are safe to call concurrently.
const [alerts, disputes] = await Promise.all([
pollAlerts(marks.alerts),
pollDisputes(marks.disputes),
]);
await writeFile(STATE, JSON.stringify({ alerts, disputes }));
console.log("caught up:", { alerts, disputes });
}
await runOnce();
```
Run it from cron every 5 minutes:
```cron
*/5 * * * * cd /srv/wp-poll && /usr/bin/node poll-job.js >> poll.log 2>&1
```
Four things that decide whether this survives contact with production:
- **Persist the mark only after the page is handled.** Writing it first means a crash
loses every row in flight, permanently, because the next run starts past them.
- **`handle()` must be idempotent.** Ordering by `updated_at` guarantees you cannot *miss*
a row, at the cost of sometimes seeing one twice. Upsert on `id`; do not append.
- **Start at 0 once, not every run.** A first sync with `highWaterMark = 0` walks your
whole history. That is intended. Re-reading 0 every run re-downloads it every time.
- **Do not run two copies at once.** Two workers sharing one mark will both do the work and
can write the older value last. Take a lock, or run it as a singleton.
If you get a `403`, the key is missing the `disputes:read` scope (add it in Dashboard →
**Developers** and retry) new keys carry it by default, on both feeds.
========================================================================
# Integration hard rules
Mistakes that silently wreck fraud scoring, double-charge, or open a hole. Read once
before you ship. The same list lives in the [reference checkout](https://app.worthypayments.com/demos/checkout-reference.html)
(View Source, plus the blue card on the page).
## Using the Payment Element? Eight of these apply
The other twelve govern server-built charges, sending a PAN rail, driving 3DS, passing the
buyer's IP, spending a decision token. The element does all of that inside the frame, so
those rules have nothing for you to get wrong. The ones that remain are yours:
| Rule | In one line |
|---|---|
| [4. Idempotency-Key](#4-idempotency-key-required-and-stable) | Required on intent create. Key it on the order, not the click |
| [10. Branch only on these outcomes](#10-branch-only-on-these-outcomes) | `onSuccess` / `onError`, nothing else |
| [11. Amounts](#11-amounts) | Integer cents. `4999` is $49.99, minimum `50` |
| [15. The browser integration is the Element](#15-the-browser-integration-is-the-payment-element) | Intent on your server, mount in the page, the whole contract |
| [16. Never persist card data](#16-never-persist-card-data) | Nothing card-shaped reaches your storage |
| [17. Lock publishable keys to your domains](#17-lock-your-publishable-keys-to-your-domains) | Miss it and the iframe renders **blank, with no error** |
| [18. Branch on `code`](#18-branch-on-code-never-on-message) | `message` is prose and gets reworded |
| [20. Don't show integration errors to shoppers](#20-dont-show-integration-errors-to-shoppers) | A request fault is not a declined card |
Rule 17 is the one that costs people their first hour, and rule 4 is the one that
double-charges. Start there.
---
## 1. Client IP
| Endpoint | Body `ip`? | What we use |
|---|---|---|
| `POST /v1/charges` | No, ignored | The buyer's: the `pk_` peer, or the `device_session` the buyer's browser created. A `sk_` peer is recorded as your server, never scored |
| `POST /v1/check` | Yes when you proxy | Body `ip`, else request peer |
- **Payment Element.** The confirm comes from the buyer's browser, so the peer is the
buyer. Nothing to do.
- **Server + `sk_` charge.** The peer is **your** egress IP, and a body IP will not fix
that. We record it as your server's address, never as a buyer IP: it is not scored and
never blocklisted, so a card-testing burst from one buyer cannot block your backend, and
saved-card renewals (`recurring: true`) are safe with no allowlist or setting. What it
costs is the buyer IP: send a
[`device_session`](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
created in the buyer's browser and the charge is scored on the buyer's IP and browser.
A renewal has no buyer at a browser and is charged without one.
- **Server + `/check`.** Set `ip` to the shopper's public IP, taken from a trusted proxy
hop. Never your server IP, and never a raw left-most `X-Forwarded-For` the client could
have forged.
Full detail: [Charges → Client IP](/developers/03-charges.md#client-ip-fraud-signal-not-a-body-field), [Fraud checks](/developers/23-fraud-checks.md#client-ip-effectively-required).
---
## 2. PAN never in cleartext
`card.number` and `card.cvv` must be Evervault tokens (`ev:…`), or the card arrives as a
single `wp:` envelope in `card.wp_token`. Raw digits are rejected at the edge, and there
is no degraded path that accepts them.
This binds anyone who builds a charge server-side. If you use the
[Payment Element](/developers/08b-payment-element.md), it is already satisfied: the card is entered
in our iframe and tokenized there, and your page never holds the digits to begin with.
---
## 3. 3-D Secure: session id only
When a charge is stepped up, the Payment Element drives 3-D Secure and resubmits for you.
The challenge opens inside the Element's frame. Nothing below is yours to wire.
Driving it yourself, server-side or from a native app, after `status: "requires_action"`:
1. Run the challenge.
2. Resubmit **the same charge** with `three_ds.session_id` added and nothing else changed
(a `three_ds.request` you sent the first time may stay).
A session that did not authenticate never charges: a failed one is a 402 (`decline_code`
`three_ds_failed` or `three_ds_card_not_enrolled`), an unfinished one a 409
`three_ds_session_incomplete`. Do not resubmit a failed session; start a new attempt.
Never post `cavv`, `eci`, `cryptogram`, or `auth_value`. We re-read the session
server-side, so a cryptogram supplied by the client is not trusted and cannot buy a
liability shift.
Do not send `three_ds` with a wallet charge. The wallet cryptogram already satisfies SCA
and the API rejects the combination.
Detail: [3-D Secure](/developers/07-3ds.md).
---
## 4. Idempotency-Key: required and stable
Money-moving POSTs require `Idempotency-Key` (or body `idempotency_key`). That includes
creating a payment intent. Omit it and you get a `400` before anything is charged.
Use a **stable key per logical payment**, your order id for instance, not a fresh UUID on
every click of Pay. A double-submit with two different keys is two charges, and the
header is the only thing standing between you and that.
Detail: [Idempotency](/developers/12-idempotency.md).
---
## 5. `/check` and `risk_decision_token` are not pre-approval
- Skipping `/check` does not weaken the charge path. Charge always runs the full engine.
- A successful `/check` does not pre-approve money movement.
- `risk_decision_token` is optional. Once sent it is strict: amount, card and device must
match. It can only **reject** a mismatch, and it never skips screening.
Detail: [Risk decision token](/developers/26-decision-token.md), [Fraud checks](/developers/23-fraud-checks.md).
---
## 6. Keys and modes
| | |
|---|---|
| `pk_…` | Browser only. Mounts the element, and belongs behind an allowed-domains list |
| `sk_…` | **Server only.** Creates intents, refunds and reads data. Never in a frontend bundle |
| Execution mode | `ATOMIC` / `FRAUD_ONLY` / `PAYMENT_ONLY` lives on the **key**, not the body |
Merchant account and test/live env come from the key, not from request fields. An `sk_`
passed to the browser SDK is rejected outright with `secret_key_in_browser`.
Detail: [Authentication](/developers/02-authentication.md), [Integration modes](/developers/25-integration-modes.md).
---
## 7. Device signals fail open
`device.fingerprint` and `device.kount_session_id` improve scoring. If CSP or the collector
fails, the charge still succeeds and you score on a weaker signal. That is the trap: empty
device data looks exactly like a healthy integration. Check CSP first.
The element attaches both signals without being asked. The two getters on the client are
there for integrations that build the charge server-side.
Detail: [Device data collection](/developers/24-device-data-collection.md).
---
## 8. Card identity for the decision token
On `/check`, send **`card_bin` + `last4`** when you have them so the token can bind the
card. Omit them and that binding is not enforced later. Nothing else is skipped: the
engine still runs, you lose only that provenance check.
---
## 9. What we derive (sending these does nothing)
On charge, do **not** try to set:
| Field | Source |
|---|---|
| IP | Request peer only |
| Whether the card is “new” / MIT | Server |
| Whether 3DS “really happened” | Session retrieve, not your claim |
| Merchant / env | API key |
---
## 10. Branch only on these outcomes
With the Payment Element:
| Callback | Meaning | Your UI |
|---|---|---|
| `onSuccess`, `status: 'succeeded'` | Money captured | Confirmation, but fulfil on the webhook |
| `onSuccess`, `status: 'processing'` | Outcome unknown | Do **not** fulfil. Wait for `charge.succeeded`, or retrieve the intent until it settles |
| `onError`, `code: 'card_declined'` | Declined | Leave the element mounted, let the buyer retry |
A decline never arrives on `onSuccess` and never throws. `status` on `onSuccess` is only
ever `succeeded` or `processing`, and `processing` is also the deliberate fallback for any
state we do not recognise. Treating it as a success is the one mistake that ships goods
that were never paid for.
Building the charge yourself:
| Response | Your UI |
|---|---|
| `status: "succeeded"` | Receipt / success |
| `status: "requires_action"` + `next_action.three_ds.session_id` | Run 3DS, then resubmit |
| `status: "processing"` (payment intent confirm) | Outcome unknown. Do **not** fulfil and do **not** confirm again. Retrieve the intent (`GET /v1/payment_intents/{client_secret}`, never charges) until it is `succeeded` or `requires_payment_method` |
| HTTP **402** / card decline | Generic "could not process". Never show raw fraud reasons to the buyer |
| HTTP **400** | Fix the request (idempotency, token mismatch, validation) |
| HTTP **503** (token store) | Retry the charge **without** the token if needed; the engine still runs |
Full JSON shapes: [Charges → Responses](/developers/03-charges.md#responses). Errors: [Errors](/developers/10-errors.md).
---
## 11. Amounts
Always cents (integer). `4999` is $49.99. A decimal is rejected rather than rounded,
because `49.99` would otherwise charge 49 cents. Minimum charge **50** cents.
The browser never sends an amount. Your server names the price when it creates the
payment intent, and the page receives a `client_secret` that addresses that decision
rather than the decision itself. Sending `amount` or `currency` to confirm returns
`parameter_unknown`: both are refused, not ignored.
When the cart total changes, your **server** writes the new total to the intent:
```bash
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_… \
-H "x-api-key: sk_live_…" -d '{"amount": 7500}'
```
That route takes the `pi_` **id**, not the client secret, and needs the server-only scope
`payment_intents:create`. Both are deliberate: the secret is in the buyer's page, so a
secret-addressed re-price would let the buyer move their own price. **The secret does not
change**, so a mounted element needs nothing. No `update()`, no remount.
Minting a second intent instead still works, and then you do hand the browser the new
secret with `element.update({ clientSecret })`. It costs you an unpaid intent left to
expire on every cart edit.
Apple Pay and Google Pay mount on the merchant page, directly above the card iframe.
The sheet total is read from the intent, so there is nothing separate to remount. They
may be off (`wallet-config` `provider: "NONE"`); that is the default.
---
## 12. HMAC (if enabled on the key)
When HMAC is on for a secret key: sign the **raw body** with timestamp + nonce. Re-serializing JSON changes the signature. Fail-closed if replay store is down.
That is **request** signing: [Authentication → Request signing (HMAC)](/developers/02-authentication.md#request-signing-hmac).
Webhook **delivery** signatures are a different HMAC: [Webhooks → Verifying a delivery](/developers/16-webhooks.md#verifying-a-delivery).
---
## 13. A `wp:` token is single use and expires in 600 seconds (10 minutes)
Both limits are hard. `GET /v1/wp-config` advertises `token_ttl_seconds: 600`; the
gateway expires the envelope at that same value. There is not a 15-minute window.
- Reusing a token gives you `wp_token_replayed`. **That includes after a decline:** a
failed charge still spends the token.
- Older than 600 seconds → `wp_token_expired`.
So: **tokenize immediately before charging, and re-tokenize for every attempt.** A "try
again" button that reuses the previous token is the single most common `wp:` integration
bug.
This is a rule for server-side integrators holding their own `wp:` tokens. The Payment
Element mints and spends a token per attempt inside the frame.
`ev:` tokens have neither limit, which is exactly why code written against `ev:` tends to
break here. See [Migrating from `ev:`](/developers/32-migration-from-ev.md).
---
## 14. Never hardcode `/wp-config`
Fetch it at runtime, cache for at most an hour, refetch. Pasting `public_key` or `kid` into
your source or environment means your integration breaks at the next key rotation, for
every card at once.
Full mechanism: [Key rotation](/developers/31-key-rotation.md).
---
## 15. The browser integration is the Payment Element
There is one way to take a card payment in a page: mount the
[Payment Element](/developers/08b-payment-element.md) against a payment intent your server created.
```
1. YOUR SERVER POST /v1/payment_intents (sk_) -> client_secret
2. BROWSER wp.mountPaymentElement('#payment', { clientSecret, onSuccess })
```
The fields live in our iframe, so your scripts cannot read them. That is the condition for
PCI **SAQ A** (~30 requirements) rather than SAQ A-EP (~139), and it is the reason the
element exists.
The public client publishes seven methods: `mountPaymentElement`, `mountExpressCheckout`,
`savedCards`, `ping`, `getFingerprint`, `getKountSessionId`, `getDeviceSession`. Card
entry, saved cards and retries happen inside the frame. 3-D Secure is driven by the
element; the challenge opens inside the frame, which grows to fit it. Apple Pay and Google
Pay mount on the merchant page, directly above the card iframe (not inside it) and only
when `GET /v1/wallet-config` reports them on. `mountExpressCheckout` draws the same wallet
buttons with no card form, for a product page or a cart; a wallet hands back an encrypted
credential, never a readable card number, so it takes no card data onto your page.
`Object.keys()` on the client shows **fifteen**, and the difference matters. The other
eight (`checkout`, `tokenize`, `createCharge`, `handle3DS`, `retrieveIntent`,
`confirmSetup`, `canMakeWalletPayment`, `mountWalletButton`) exist at runtime because the
element's own frame reaches them through `internals()`. They are not API: TypeScript does
not declare them, nothing supports them, and they change without a version bump. Calling
`tokenize` or `createCharge` from your page puts card data back on your page and re-opens
the SAQ A-EP path this design exists to close. Counting the keys is not a way to discover
features.
Two paths avoid a card UI on your side entirely: a
[hosted checkout link](/developers/04-checkout-links.md) you redirect to, and a
[server-to-server charge](/developers/03-charges.md) if you already tokenize cards on your own
backend. The second puts you in SAQ A-EP. Choose it deliberately.
---
## 16. Never persist card data
Not in `localStorage`, `sessionStorage`, a cookie, a hidden form field, your database, or
your logs. Hold raw card values in a variable for as long as it takes to tokenize, then
drop them.
If you need the card again for a retry after a decline, re-prompt for it. Do not stash it.
With the element this is not your problem: the card stays in the frame, the frame stays
mounted through a decline, and the buyer retries in place.
---
## 17. Lock your publishable keys to your domains
Two gates share the same list and diverge on an empty one. Know which you hit:
| Surface | Empty `allowed_domains` | Non-empty, origin not listed |
|---|---|---|
| **Payment Element framing** | Fail **closed**: blank iframe (`frame-ancestors 'none'`) | Blank iframe |
| **API DomainGuard** (charge/confirm with `Origin`) | Fail **open**: no origin check | `domain_not_allowed` |
The Element path is the one that matters for checkout: **you must have domains set** or
the form never renders. Once a list is set, a request whose `Origin` (or `Referer`)
hostname is not on it is refused with `domain_not_allowed` on the API, and the element
stays blank. A publishable key that sends no `Origin` or `Referer` at all is also refused
on locked keys, since an origin-less request from a browser key is what a stolen key
replayed server-to-server looks like. Secret keys are exempt from that last part: they
are expected to make origin-less calls.
Matching is exact hostname, or a `*.example.com` wildcard covering **exactly one label**.
| Pattern | Host | Match |
|---|---|---|
| `*.example.com` | `www.example.com` | yes |
| `*.example.com` | `example.com` | **no** |
| `*.example.com` | `a.b.example.com` | **no** |
**Important:** A wildcard does not cover your apex. If buyers check out at both
`example.com` and `www.example.com`, list both. A wildcard alone leaves the apex
refused with `domain_not_allowed`, and because the element renders blank rather than
erroring, it looks like the frame is broken rather than the allowlist being short.
> Set **Allowed domains** in the key-creation dialog under **Settings → Developers →
> API keys**. Publishable keys require at least one hostname; without it the Payment
> Element frames as a blank iframe on purpose.
>
> Change the list later without minting a key: **Edit allowed domains** (the globe icon) on
> the key's row in the same table. Rolling a key copies the existing list forward rather than
> replacing it.
>
> Secret keys support an IP allowlist through the same route. It matches exact addresses;
> CIDR ranges are not supported on this path, so a wide egress range needs each address
> listed.
---
## 18. Branch on `code`, never on `message`
`message` is human-readable prose and may be reworded at any time. `code` is the stable
contract. `type` gives you the category.
```js
if (err.code === 'wp_token_expired') { /* … */ } // stable
if (err.message.includes('expired')) { /* … */ } // will break
```
---
## 19. Retry only what is retriable
- **Retry with backoff:** `429`, `5xx`, network failures. Honour `Retry-After`.
- **Retry once, after fixing state:** `wp_token_expired` / `wp_token_replayed`
(re-tokenize). Also `wp_token_malformed` **once**. It is normally a real defect, but it
is also what a retired key looks like, and one refetch-and-retry covers that case. Once,
not a loop.
- **Never retry blindly:** `wp_token_too_large`, `wp_token_audience_mismatch`,
`raw_pan_rejected`, `pan_encryption_required`, `idempotency_error`, `permission_denied`,
`account_not_activated`, `parameter_invalid`. These are integration faults; the same
request always fails.
- **Never retry the same card:** hard declines (`pickup_card`, `fraudulent`).
---
## 20. Don't show integration errors to shoppers
Every `wp_token_*` code is a fault in the request, not a problem with the card. Showing
"your card was declined" is wrong and sends the shopper to a different card that will fail
identically. Show a generic retry prompt and alert your own monitoring.
---
## 21. Your checkout page must be HTTPS
Card encryption uses WebCrypto, which browsers expose only in a secure context. On plain
HTTP `crypto.subtle` is absent, so sealing a card fails with `insecure_context` rather
than degrading to something weaker. `localhost` is exempt for development; everything else
needs TLS. The exemption is the page origin (`Origin: http://localhost:…`), not a loopback
address in a public URL query (`?origin=http://localhost…` is HTML 403 at the edge. See
[Errors) loopback in the URL](/developers/10-errors.md#loopback-in-the-url).
A page served over `http` is a problem before any of that: the card is in the DOM and the
encryption key arrived over a channel anyone on the path could have answered. Serve
checkout over TLS whether you mount the element or seal cards yourself. There is no
fallback worth having, since the API rejects raw PANs regardless.
---
## 22. Unknown fields are refused
Top-level bodies are allowlisted. An extra key is `400 parameter_unknown` (`param`
names the field), not a silent ignore. The names Stripe (and our *other* routes)
use are the usual miss:
| Route | Refused | Use |
|---|---|---|
| `POST /v1/charges` | `email`, `receipt_email`, `billing`, `order_reference` | `customer.email`, `billing_address`, `description` / `metadata` |
| `POST /v1/payment_intents` | `order_reference`, `billing`, a customer **object** | `reference`, `receipt_email`, `customer` as a `cus_…` **id** |
| Confirm (`…/confirm`) | `amount`, `currency`, `billing_address` | Amount is on the intent. Billing is `billing` (email / name / address) |
| `POST /v1/subscriptions` | `plan_id` | `plan`. `customer` is required |
Same envelope as a missing required field, different `code`. Branch on `code`.
See [Charges](/developers/03-charges.md) and [Errors](/developers/10-errors.md).
---
## Pre-launch checklist
- [ ] No raw PAN is sent anywhere, from any surface.
- [ ] No card data is persisted, logged, or put in browser storage.
- [ ] The amount is set only by the server that creates the intent.
- [ ] The SDK bundle URL is the pinned `/sdk/v1/` path, not the unversioned one.
- [ ] `processing` on `onSuccess` does not fulfil the order; the webhook does.
- [ ] A decline leaves the element mounted so the buyer can retry.
- [ ] Cart changes `PATCH` the existing intent from the server (the client secret is unchanged).
- [ ] `/wp-config` is fetched at runtime with a real cache TTL.
- [ ] No `sk_` key appears in any client bundle.
- [ ] Publishable keys are domain-locked at mint (**Settings → Developers → API keys → Allowed domains**).
- [ ] Amounts are integer cents. A new charge is ≥ 50; a capture or refund amount, when sent, is a whole number ≥ 1.
- [ ] `Idempotency-Key` is sent on every intent, charge, capture, void and refund.
- [ ] Charge / intent / confirm bodies use this API's field names (`billing_address` vs
`billing` vs `receipt_email`), extra keys are `parameter_unknown`.
- [ ] Error handling branches on `code`.
- [ ] The decline path is tested with an amount below 1.00 in test mode (`"amount": 99`; the
sandbox has no decline card), including the retry.
- [ ] Checkout is served over HTTPS.
If you build charges server-side, also:
- [ ] Tokenization happens immediately before the charge, not ahead of time.
- [ ] Every retry path re-tokenizes, decline retries especially.
- [ ] `/check` sends the shopper's IP, not your egress IP.
Then: [Going live](/developers/21-going-live.md).
========================================================================
# WorthyPayments Developer Documentation
Accept card payments with fraud screening, acquirer routing, and 3-D Secure applied
on every charge. Integrate with the browser SDK, the server-side REST API, or the
WooCommerce plugin.
These pages are English. A Turkish translation is not published.
```
Base URL https://api.worthypayments.com/v1
SDK
Element https://element.worthypayments.com (iframe origin; not your app origin)
```
Use those hosts as published. White-label or post-cutover deploys may rewrite them to
your own domain automatically in the panel docs. You do not hand-edit hosts per
environment. Prefer the public-api host (`https://api.worthypayments.com/v1`), not the
merchant dashboard BFF (`/api/v1`). Load the SDK from
the pinned `/sdk/v1/` path: the unversioned `/sdk/worthypayments.umd.js` always serves
the newest build and will change under you.
### Two domains
Every host above also answers on paygeyt.com: `https://api.paygeyt.com/v1`,
`https://app.paygeyt.com/sdk/v1/worthypayments.umd.js` and `https://element.paygeyt.com`.
They are the same API, keys, data and SDK file, so an integration built on paygeyt.com
keeps working and needs no change.
Keep one domain per page. The SDK uses the API and the card frame of the domain it was
loaded from, so a page that loads it from `app.worthypayments.com` needs only
worthypayments.com hosts in its CSP. Checkout and invoice links that
the API returns (`url`, `hosted_invoice_url`) use `app.worthypayments.com`; links issued
earlier on `app.paygeyt.com` keep working.
> **Two rules that hold everywhere in these docs:**
> 1. **All amounts are integers in the smallest currency unit.** `$49.99` is `4999`.
> 2. **Card data is tokenized before it reaches the API.** A raw PAN is rejected at the
> boundary. If you use the Payment Element, this is already handled and you can skip
> the rail table below. It matters when you build the charge server-side and call
> `POST /v1/charges` yourself:
>
> | Rail | What you send | When |
> |---|---|---|
> | `ev:` (default) | `card.number` and `card.cvv`, both `ev:…` | Anything that may step up to 3-D Secure. 3DS consumes this rail. |
> | `wp:` | `card.wp_token`, one `wp:v1:…` envelope | Our own tokenization. PAN, expiry and CVV are sealed together, so there is **no** `wp:` cvv. |
>
> See [Card tokenization](/developers/28-card-tokenization.md).
> **Before you ship:** read [Integration hard rules](/developers/27-integration-hard-rules.md). On
> the Payment Element only [eight of the
> twenty](/developers/27-integration-hard-rules.md#using-the-payment-element-eight-of-these-apply)
> apply. The rest govern server-built charges. The same list is on the [reference
> checkout](/demos/checkout-reference.html).
---
## Should I redirect or embed?
Two ways to take a payment. Pick one per integration.
| | **Redirect (hosted checkout)** | **Payment Element (embedded)** |
|---|---|---|
| Where the buyer pays | On a WorthyPayments-hosted page | On your page, in an iframe we serve |
| Where the card is entered | Our page | Our iframe, never your DOM |
| PCI surface | SAQ A (~30 requirements) | SAQ A (~30 requirements) |
| Key used | Secret key (`sk_…`), server-side | `sk_…` server-side for the intent, publishable key (`pk_…`) in the browser |
| Build effort | One server call, then redirect | One server call, then one `mountPaymentElement()` |
| UI you own | Page branding (logo, colors) | Everything around the element; the fields themselves are ours to style via `appearance` |
| 3-D Secure | Handled on the hosted page | Handled inside the element |
| Apple / Google Pay | On the hosted page | On your page, above the card iframe (same algorithm) |
Both paths keep the card out of your code, so PCI scope is no longer the thing that
decides this. What differs is where the buyer finishes and how much of the checkout you
build. Redirect is less to write and is what the WooCommerce plugin ships with. The
Payment Element keeps the buyer on your domain and lets you own the surrounding page,
at the cost of rendering and styling a form. If you are already sending buyers away at
checkout, redirect is fine. If leaving your site is a problem, use the element.
Redirect in five lines:
```bash
curl https://api.worthypayments.com/v1/checkout-links \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"Order #1234","amount":4999,"currency":"usd","link_type":"FIXED"}'
# → { "url": "https://app.worthypayments.com/checkout/abc123", .. } ← redirect the shopper here
```
The Payment Element ([full guide](/developers/08b-payment-element.md)):
```bash
# 1. Your server names the price. Never the browser.
curl https://api.worthypayments.com/v1/payment_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: order_1234" \
-H "Content-Type: application/json" \
-d '{"amount":4999,"currency":"usd","reference":"order_1234"}'
# → { "client_secret": "...", .. }
```
`Idempotency-Key` is required here, not advisory: without it the call is rejected
before an intent exists. Key it on your order, not on the click. The create route needs
the server-only scope `payment_intents:create`; confirm is scope `none` and authorised by
the `client_secret` alone.
```html
```
The card fields live in an iframe we serve, so your page never touches a card number
(PCI **SAQ A**). The price lives on the intent: sending `amount` on confirm is
`parameter_unknown`, not a silent ignore.
**`POST /v1/payment_intents` body**
| Field | Required | Notes |
|---|:---:|---|
| `amount` | Yes | Integer minor units (`4999` = $49.99) |
| `currency` | No | Defaults to `usd` |
| `description` | No | Line label on the intent |
| `reference` | No | Your order/cart id (webhooks/charges echo it) |
| `customer` | No | Existing customer id |
| `receipt_email` | No | Receipt destination. Send this **or** `billing.email` on confirm, with neither, the confirm fails `failed_precondition` / `customer email is required` |
| `Idempotency-Key` header | Yes | Or body `idempotency_key` |
One setup step is easy to miss. Set **Allowed domains** when you mint the publishable
key (**Settings → Developers → API keys**). Empty list or wrong origin → **blank iframe**
(no error). Parent page must be **https**. Update the list on the key without rolling
the secret. See
[Integration hard rules](/developers/27-integration-hard-rules.md).
Appearance, locale, saved cards, and the full option list:
[Payment Element](/developers/08b-payment-element.md) · [Embedded SDK](/developers/08-embedded-sdk.md).
---
## Start here: three pages
To take a card payment with the Payment Element you need this page, then two more:
| Read | Why |
|---|---|
| **[01 Quickstart](/developers/01-quickstart.md)** | Key, allowed domains, intent, mount. A working charge end to end |
| **[08b Payment Element](/developers/08b-payment-element.md)** | The full option list: appearance, locale, saved cards, wallets |
Everything else below is reference for when you need it. In particular you do **not** need
to call a fraud endpoint, drive 3-D Secure, or collect device data yourself. The element
and the charge engine do all three. The pages describing them exist for server-built
integrations that bypass the element.
## Documentation index
The public docs home is [https://app.worthypayments.com/developers](https://app.worthypayments.com/developers)
(use-case landing). The [API reference](https://app.worthypayments.com/developers/api-reference)
is the endpoint surface. Every guide is also HTML at `/developers/` (raw
markdown still at `/developers/.md`). `/developers/guides` redirects home.
**Taking payments**, 01, 03, 04, 08b · **Money movement**, 06, 17, 18, 18b, 18c ·
**Operating**, 10, 12, 13, 14, 15, 16, 19, 21, 33 · **Advanced / server-built**, 07, 23,
24, 25, 26, 28, 29, 30, 32
| # | Page | What it covers |
|---|------|----------------|
| | [README](/developers/README.md) | This page: overview, redirect vs Payment Element |
| 01 | [Quickstart](/developers/01-quickstart.md) | Create a key, then a first charge, in curl and in ~10 lines of SDK |
| 02 | [Authentication](/developers/02-authentication.md) | `x-api-key`, `pk_`/`sk_`/`rk_` prefixes, test vs live, the scopes table |
| 03 | [Charges](/developers/03-charges.md) | `POST /charges` full contract, amounts in minor units, `requires_action`, decline codes |
| 04 | [Checkout Links](/developers/04-checkout-links.md) | `POST /checkout-links`, the hosted redirect flow |
| 05 | [Products](/developers/05-products.md) | `GET /products`, the fixed-product share-a-URL flow |
| 05b | [Storefronts](/developers/05b-stores.md) | `GET /v1/stores`, stats, retrieve |
| 06 | [Refunds](/developers/06-refunds.md) | `POST /refunds`, full and partial |
| 06b | [Refund a customer's past charges](/developers/06b-refund-past-charges.md) | Refund what a customer spent instead of sending a payout |
| 07 | [3-D Secure](/developers/07-3ds.md) | `next_action` handling for server-built charges. The element does this internally. |
| 08 | [Embedded SDK](/developers/08-embedded-sdk.md) | Browser SDK reference: every method, option, callback, and TypeScript type |
| 08b | [Payment Element](/developers/08b-payment-element.md) | Task guide: mount the element, wire the intent, saved cards, wallets, appearance |
| 09 | [WooCommerce](/developers/09-woocommerce.md) | Install, key setting, redirect/embed toggle |
| 10 | [Errors](/developers/10-errors.md) | The error envelope, `type`/`code`/`decline_code`, HTTP map |
| 11 | [Health](/developers/11-health.md) | `GET /ping`, key + mode + scope check |
| 12 | [Idempotency](/developers/12-idempotency.md) | `Idempotency-Key`, safe retries, the 409 in-progress case |
| 13 | [Pagination](/developers/13-pagination.md) | Cursor lists: `limit`, `starting_after`, `has_more` |
| 14 | [Rate limits](/developers/14-rate-limits.md) | Per-key limits, `429`, `Retry-After` |
| 15 | [Testing](/developers/15-testing.md) | Test mode, test cards, `POST /test/tokens`, `POST /test/subscriptions/{id}/bill`, the curl-only first charge |
| 16 | [Webhooks & polling](/developers/16-webhooks.md) | Registering an endpoint, signature verification, payload shapes, **and the polling APIs for disputes and pre-dispute alerts** (`GET /v1/disputes`, `GET /v1/pre_dispute_alerts`) |
| 17 | [Authorize & Capture](/developers/17-auth-capture.md) | `capture_method: manual`, capture (partial), void |
| 18 | [Customers & Saved Cards](/developers/18-customers.md) | `/customers`, save a card, charge a saved card |
| 18b | [Subscriptions](/developers/18b-subscriptions.md) | `POST /plans`, start (including `default_incomplete` → Element), modify/cancel, TEST `bill` |
| 18c | [Invoices](/developers/18c-invoices.md) | `GET /v1/invoices`; PAID row on auto-charge renewal; `hosted_invoice_url` after finalize |
| 19 | [Events](/developers/19-events.md) | `GET /events`, webhook-endpoint management |
| 20 | [Versioning](/developers/20-versioning.md) | `API-Version`, `Request-Id`, dated versions |
| 21 | [Going Live](/developers/21-going-live.md) | Production checklist, key rolling, the test to live switch |
| 22 | [Libraries & SDKs](/developers/22-libraries.md) | Browser SDK, generating a client from OpenAPI |
| 23 | [Fraud checks](/developers/23-fraud-checks.md) | **Advisory, not required.** Pre-checkout risk evaluation. Charges are screened server-side either way. |
| 24 | [Device data collection](/developers/24-device-data-collection.md) | Fingerprint + Kount, **for server-built charges**. The element collects both for you. |
| 25 | [Integration modes](/developers/25-integration-modes.md) | Atomic, two-step, fraud-only: which surfaces your key can reach |
| 26 | [Risk decision token](/developers/26-decision-token.md) | Tying a `/check` verdict to the charge that follows it. Only if you call `/check`. |
| 27 | [**Integration hard rules**](/developers/27-integration-hard-rules.md) | **Must-read before ship:** IP, 3DS, idempotency, tokens, keys, UI |
| 28 | [Card tokenization](/developers/28-card-tokenization.md) | `wp:` tokens: quickstart, test cards, encrypting without the SDK |
| 29 | [`wp:` rail reference](/developers/29-sdk-reference.md) | The tokenization rail for server-side and non-browser integrations: parameters, return shape, types, errors |
| 30 | [Tokenization error codes](/developers/30-error-codes.md) | The seven `wp:` codes: cause, fix, and whether to retry |
| 31 | [Key rotation](/developers/31-key-rotation.md) | Why rotation is invisible, and the one mistake that opts you out |
| 32 | [Migrating from `ev:`](/developers/32-migration-from-ev.md) | Side-by-side changes for existing Evervault integrations |
| 33 | [Metrics](/developers/33-metrics.md) | `GET /v1/metrics` KPI roll-up |
| | [Changelog](/developers/changelog.md) | Dated API versions |
---
## Machine-readable spec & tools
| Artifact | File | Use |
|---|---|---|
| **OpenAPI 3.1** | [`openapi.yaml`](/developers/openapi.yaml) | Import into Postman / Insomnia / Stoplight; generate clients |
| **Postman collection** | [`worthypayments.postman_collection.json`](/developers/worthypayments.postman_collection.json) | Every endpoint pre-built. Set the `base_url` and `api_key` collection variables and run. |
| **Scalar spec** | [`openapi.scalar.yaml`](/developers/openapi.scalar.yaml) | Powers the hosted API reference. The same endpoints as `openapi.yaml`, with the sidebar grouping the reference UI needs. Read it in the browser; don't import it. |
> **Two spec files, on purpose.** `openapi.yaml` is the endpoint surface on its own,
> which is what code generators and API clients want. `openapi.scalar.yaml` is that
> same surface plus the sidebar grouping the hosted reference renders. Import the
> first; browse the second.
>
> **The guides are not in either of them.** They are served as HTML at
> `/developers/` and as markdown at `/developers/.md`, and that is the only
> copy. The Scalar spec used to carry nineteen of them as tags as well; seventeen were
> hand-maintained, drifted from these files, and one had gone wrong enough to send an
> integrator down a broken test card. One copy, one source.
Both cover the full public surface (payment intents, charges, refunds, subscriptions,
customer sessions, checkout links, products, storefronts, metrics, wallet / wp / Evervault
/ Kount config, fraud check, test tokens, ping, webhooks, …). They are generated from
`docs/endpoints/E1-public-api.openapi.yaml`, and a CI gate re-runs the generators on every
pull request and fails the build when a committed copy differs, so none of the three
published copies can drift from E1:
```bash
node apps/merchant-dashboard/scripts/sync-developer-openapi.mjs
node apps/merchant-dashboard/scripts/sync-developer-postman.mjs
node edges/public-api/scripts/gen-openapi-json.mjs # serves /v1/openapi.json
```
> The gate is `.github/workflows/developer-docs-drift-gate.yml`. It exists because the
> "cannot drift" claim used to be an assertion rather than a check: the OpenAPI sync threw
> on a missing sidebar tag group and wrote neither file, silently, and the two specs sat
> seven live routes behind the edge while this page said that was impossible. ⚠️ **It
> binds the copies to E1. It does not bind E1 to the edge.** Nothing in this repository
> compares `docs/endpoints/E1-public-api.openapi.yaml` against the controllers under
> `edges/public-api/src`, so a route that exists in code and was never written into E1 is
> invisible to every copy downstream of it. Writing the route into E1 stays a manual step
> in the PR that adds it. Four live routes are in exactly that state today; they are
> listed in the follow-ups of `docs/webhook-enrichment-plan.md`.
---
## A note on acquirers
WorthyPayments routes each charge to an acquirer behind the scenes: `nmi` (primary gateway),
`payarc`, `nuvei`, or `worthy`. You don't choose the acquirer in the public API. Routing is
resolved server-side from your key and merchant configuration, and the acquirer is **not**
exposed on the public charge object.
========================================================================
# Authentication
Every request to `https://api.worthypayments.com/v1` is authenticated with an API key.
## Cloudflare User-Agent
Cloudflare sits in front of the API. A request whose `User-Agent` is **missing or empty**
is answered as **1010**. An HTML interstitial, not the JSON error envelope in
[Errors](/developers/10-errors.md). Curl's default UA and Firefox pass; Chrome is not required.
Examples still send `-A` so a stripped header cannot sneak in. Browser SDKs already send a
real UA.
```
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
```
## The header
Send your key in the `x-api-key` header:
```bash
curl https://api.worthypayments.com/v1/ping \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_live_xxx"
```
A `Bearer` token is also accepted for convenience:
```bash
curl https://api.worthypayments.com/v1/ping \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "Authorization: Bearer sk_live_xxx"
```
If the key is missing, invalid, expired, or revoked you get `401` with
`code: "api_key_required"` (no key sent) or `"api_key_invalid"` (unknown, expired, or
revoked). See [Errors](/developers/10-errors.md).
---
## Key types
A key's prefix encodes both its **type** (what it can do) and its **mode** (test or live).
| Prefix | Type | Where it lives | What it can do |
|--------|------|----------------|----------------|
| `pk_live_…` / `pk_test_…` | **Publishable** | The browser (safe to embed in your web page) | `payments:charge` plus endpoints that require no scope (ping, config, confirm/retrieve a payment intent by `client_secret`, test tokens). Never server-only writes. It charges fresh `ev:`/`wp:` cards and wallets only: it cannot charge a saved card, capture, or void (`403 permission_denied`). |
| `sk_live_…` / `sk_test_…` | **Secret** | Your server. Never expose it | Everything its granted scopes allow (intents, refunds, checkout links, reads). |
| `rk_live_…` / `rk_test_…` | **Restricted** | Your server | A custom subset of scopes you choose when minting the key. |
### Publishable (`pk_`)
Designed to be shipped to the browser. The server **narrows a publishable key to
`payments:charge` plus scope-`none` endpoints**, regardless of anything stored on it.
A `pk_` key can never refund, create a payment intent or checkout link, mint a customer
session, or read data, even if those scopes were somehow attached. It *can* confirm a
payment intent whose `client_secret` your server already handed to the page. Use it with
the [SDK](/developers/08-embedded-sdk.md) / [Payment Element](/developers/08b-payment-element.md):
```js
const wp = WorthyPayments('pk_live_xxx');
```
**Allowed Domains:** browser keys are locked to an origin allowlist. Set the list when you
create the key under **Settings → Developers → API keys**. The create dialog exposes
**Allowed domains**, and publishable keys require at least one hostname. Update the list
on the key later without rolling the secret. A new checkout origin does not need a new
`pk_`. See [Integration hard rules](/developers/27-integration-hard-rules.md) for the matching rules
and what happens when a request arrives with no `Origin`.
Two gates share that list: **Payment Element framing fails closed** when the list is empty
(blank iframe); the **charge API DomainGuard fails open** on an empty list (no origin
lock) and only rejects once domains are set. Once domains are set, a `pk_` request without
a matching `Origin` / `Referer` is rejected. That is what stops a stolen publishable key
from being replayed as pure S2S traffic.
`localhost` is a normal allowlist entry for local development. Send it as the `Origin`
header. Do not put `http://localhost` in the URL query
(`/v1/element-frameable?origin=http://localhost…`) (that is HTML 403 at the public edge,
not `domain_not_allowed`. See [Errors) loopback in the
URL](/developers/10-errors.md#loopback-in-the-url).
**Merchant + env** (test vs live) come from the **key**, not from body fields. You cannot
switch merchant by forging JSON.
### Secret (`sk_`)
Full-power, server-only. Carries whatever scopes you granted it. Use it for payment
intents, refunds, checkout links, products, customer sessions, and charge reads.
> **Never ship a `sk_` key to the browser.** If a secret key is ever exposed (committed
> to a repo, embedded in client JS, pasted in a ticket), revoke it in the dashboard
> immediately and mint a new one. The [reference checkout](/demos/checkout-reference.html)
> refuses `sk_` keys outright for this reason.
### Restricted (`rk_`)
A secret-style server key limited to the exact scopes you select at creation time. Use it
to give a service the least privilege it needs, e.g. a reporting job that only holds
`payments:read`.
---
## Execution mode
Separate from scopes, every key carries an **execution mode** that decides which halves of
the API it can reach at all. Scope says *what* an endpoint needs; mode says *which surfaces
this integration uses*.
| Mode | `POST /check` | `POST /charges` | For |
|------|:---:|:---:|---|
| `ATOMIC` | yes | yes | Most integrations. One call, or the two-step flow. |
| `FRAUD_ONLY` | yes | no (`403`) | You have your own acquirer and want our screening. |
| `PAYMENT_ONLY` | no (`403`) | yes | Charging only, without the standalone advisory surface. |
The mode is read from the **key**, never from a request field. You cannot widen your own
mode by sending a parameter, and neither can anyone who obtains your key. A `FRAUD_ONLY`
key cannot move money under any circumstances.
`PAYMENT_ONLY` does **not** mean "skip fraud". Every charge runs the full engine in every
mode. See [Integration modes](/developers/25-integration-modes.md).
---
## Test mode vs live mode
Mode is encoded in the prefix, not a separate header:
- `pk_test_` / `sk_test_` / `rk_test_` → **test mode**
- `pk_live_` / `sk_live_` / `rk_live_` → **live mode**
Test-mode charges run the **full engine**. Fraud screening, routing decisions and 3-D
Secure all execute exactly as they will in production, then settle against a sandbox
acquirer, so no real money moves. Build and verify your integration entirely in test
mode, then swap the prefix to `live` to go live. Nothing else in your code changes.
`GET /ping` echoes the resolved mode so you can confirm which key you are holding:
```json
{
"object": "ping",
"mode": "test",
"key_type": "publishable",
"merchant_account_id": "cmt3yfavu00013iokngc9rjtw",
"api_version": "2026-01-01",
"scopes": ["payments:charge"],
"unrestricted": false
}
```
The response also lists the key's resolved scopes, which is usually the quickest way to
diagnose a `403 permission_denied`. A publishable key always reports exactly
`["payments:charge"]`: the server narrows every `pk_` to that regardless of what is stored
on it, so the field cannot disclose a privilege the key type did not already imply. The
list is derived from the same rules the scope guard enforces, never from the stored
permission map on its own. A listing that disagreed with the gate would be worse than
none, since the point of the field is explaining a refusal.
`unrestricted: true` appears only on a legacy secret key with no permission map at all,
which still carries full access. The dashboard labels that key "Unrestricted (legacy
key)"; reporting `scopes: []` for it would read as "denied everywhere", which is the
opposite of the truth.
---
## Scopes
A key may call an endpoint only if it holds the required scope.
**Legacy exception:** a **secret** key whose permission map is completely empty (the
pre-scopes shape still present on some older keys) keeps **full access**. That is not
least privilege. The dashboard labels it “Unrestricted (legacy key)”. A restricted key or
any key whose scopes were set and then cleared is deny-all for scoped routes. New keys
minted from the dashboard always carry an explicit scope list.
| Scope | Grants | Endpoint(s) | Server-only? |
|-------|--------|----------|:---:|
| `payments:charge` | Create a charge | `POST /charges`, capture, void | No (implicit on `pk_`) |
| `payments:read` | Read charges & refunds | `GET /charges`, `GET /charges/:id`, `GET /refunds`, `GET /refunds/:id`, `GET /customers/:id/transactions` | No |
| `payment_intents:create` | Create a payment intent (names the amount) | `POST /payment_intents` | **Yes** |
| `customer_sessions:create` | Mint a customer session for saved cards | `POST /customer_sessions` | **Yes** |
| `refunds:create` | Refund a charge | `POST /refunds` | **Yes** |
| `checkout_links:create` | Create a hosted checkout link | `POST /checkout-links` | **Yes** |
| `checkout_links:read` | List and fetch checkout links | `GET /checkout-links`, `GET /checkout-links/:id` | **Yes** |
| `products:read` | List products | `GET /products` | No |
| `tax:read` | Read tax rates, calculate tax | `GET /tax_rates`, `GET /tax_rates/:id`, `POST /tax/calculate` | No |
| `tax:write` | Create, update, delete tax rates; attach them to products | `POST /tax_rates`, `POST|DELETE /tax_rates/:id`, `POST /products/:id/tax_rates`, `DELETE /products/:id/tax_rates/:tax_rate` | **Yes** |
| `stores:read` | List and fetch storefronts | `GET /stores`, `GET /stores/stats`, `GET /stores/:id` | No |
| `metrics:read` | Read KPI tiles | `GET /metrics` | No |
| `customers:read` | Read customers & saved cards | `GET /customers`, `GET /customers/:id`, `GET /customers/:id/payment_methods` | No |
| `customers:write` | Create customers, save/delete cards | `POST /customers`, `POST|DELETE /customers/:id/payment_methods` | **Yes** |
| `plans:read` | List and fetch plans | `GET /plans`, `GET /plans/:id` | No |
| `plans:write` | Create a plan | `POST /plans` | **Yes** |
| `subscriptions:read` | Read subscriptions | `GET /subscriptions`, `GET /subscriptions/:id` | No |
| `subscriptions:write` | Create, modify, cancel, or test-bill a subscription | `POST /subscriptions`, `PATCH`/`DELETE /subscriptions/:id`, `POST /test/subscriptions/:id/bill` | **Yes** |
| `invoices:read` | List and fetch invoices | `GET /invoices`, `GET /invoices/:id` | No |
| `invoices:write` | Email an invoice | `POST /invoices/:id/send` | **Yes** |
| `webhooks:read` | Read webhook endpoints | `GET /webhook-endpoints`, `GET /webhook-endpoints/:id` | No |
| `webhooks:write` | Manage webhook endpoints | `POST|DELETE /webhook-endpoints`, roll-secret | **Yes** |
| `events:read` | Re-fetch past events | `GET /events`, `GET /events/:id` | No |
| `disputes:read` | Poll disputes and pre-dispute alerts | `GET /disputes`, `GET /disputes/:id`, `GET /pre_dispute_alerts`, `GET /pre_dispute_alerts/:id` | No |
| `threeds:read` | Read 3DS config & sessions | `GET /3ds/config`, `GET /3ds/sessions/:id` | No |
| `threeds:create` | Open / retry a 3DS session | `POST /3ds/sessions`, `POST /3ds/retry-decline` | **Yes** |
| `check:read` | Pre-checkout fraud evaluation | `POST /check`, `POST /check/recheck` | No |
| `blocklist:read` | Read the blocklist | `GET /blocklist` | No |
| `report:write` | Report abuse to the blocklist | `POST /report` | **Yes** |
| `fraud:read` | Read fraud rules, workflow routes, a customer's Require 3DS flag | `GET /fraud/rules`, `GET /payment_workflow/routes`, `GET /customers/:id/three_ds` | No |
| `fraud:write` | Set / clear a customer's Require 3DS flag | `POST /customers/:id/three_ds`, `DELETE /customers/:id/three_ds` | **Yes** |
> Scope-`none` endpoints need only a valid key: `/ping`, `/wallet-config`,
> `/evervault-config`, `/wp-config`, `/kount-config`, `/element-frameable`,
> `/test/tokens`, `POST /payment_intents/:client_secret/confirm`, `GET
> /payment_intents/:client_secret`, and customer-session payment-method reads that carry a
> session secret. Confirm/retrieve are scope `none` because the **client secret is the
> capability**. A publishable key never needs a write scope to finish a payment. Creating
> the intent stays server-only (`payment_intents:create`) because that is where the amount
> is decided.
**Server-only scopes** can never be satisfied by a publishable key. If a `pk_` key
attempts one of those endpoints, the request is rejected with `403` and
`code: "permission_denied"`. Creating a payment intent is server-only on purpose: that is
where the amount is decided, so a browser key must never mint one.
A publishable key is implicitly limited to `payments:charge` and the no-scope endpoints.
Within `payments:charge` it can only create a charge with a freshly tokenized card or a
wallet. Charging a saved card, `POST /v1/charges/:id/capture` and `POST
/v1/charges/:id/void` need a secret key: the publishable key is public, and a void can
become a full refund.
`disputes:read` is the one scope a new secret key does **not** get by default. Dispute and
pre-dispute payloads carry card and cardholder detail (BIN, last 4, ARN, order id) that
most integrations never need, so it is granted deliberately rather than inherited, tick it
under Dashboard → **Developers** on the key that polls. It holds **no** read scopes, so
even `payments:read` is denied to it.
### Scope errors
```jsonc
// 403 — missing scope (secret without the grant, or any publishable on a server-only route)
{ "error": { "type": "invalid_request_error", "code": "permission_denied",
"message": "API key missing scope refunds:create" } }
```
Branch on `code` (`permission_denied`), not on the exact `message` wording. Every error
follows the same `{ "error": { "type", "code", "message" } }` envelope. See
[Errors](/developers/10-errors.md).
---
## Creating and rotating keys
1. Dashboard → **Developers → API keys**.
2. Click **Create key**, choose the type (publishable / secret / restricted) and mode.
For a restricted key, select the exact scopes.
3. Copy the key on creation. The secret portion is shown **once**.
4. To rotate, create a new key, deploy it, then revoke the old one. Revocation is
immediate; in-flight requests with the old key start failing with `401`.
Keys are scoped to a single merchant account and respect any IP allowlist configured on
them. Set it when you create a secret or restricted key (**Allowed server IPs** in the
create dialog): exact IPv4 or IPv6 addresses, no ranges. A request from any other address
is refused with `401 api_key_invalid`. The dashboard sets this list only when the key is
created (unlike allowed domains, which you can edit later), so to change it, create a new
key and rotate.
---
## Request signing (HMAC)
Optional, and **off unless you enable it** when you create a secret or restricted key
(**Require signed requests (HMAC)** in the create dialog). The signing secret is shown
once, next to the key. Once enabled, the key stops working without a valid signature, so
turn it on in test mode first.
> **Hard rule when HMAC is on:** sign the **raw body** bytes you send. Re-serializing
> JSON (pretty-print, key reorder) changes the signature. Fail-closed if the replay
> store is down. See [Integration hard rules §12](/developers/27-integration-hard-rules.md#12-hmac-if-enabled-on-the-key).
Signing proves the request body was not altered in transit and cannot be replayed. Send
three headers alongside your API key:
| Header | Value |
|---|---|
| `X-Timestamp` | Unix seconds. Rejected if more than **300s** from server time. |
| `X-Nonce` | A random string, unique per request. Accepted **once**, then remembered for 300s. |
| `X-Signature` | Hex `HMAC-SHA256(secret, "..")` |
The signed payload is the timestamp, the nonce and the **raw request body** joined by
literal dots. For a GET with no body, use an empty string in the third position. Sign the
exact bytes you send. Re-serializing the JSON changes the signature.
```js
import { createHmac, randomUUID } from "node:crypto";
const body = JSON.stringify({ amount: 4999, currency: "usd", card });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
const signature = createHmac("sha256", process.env.WP_HMAC_SECRET)
.update(`${timestamp}.${nonce}.${body}`)
.digest("hex");
await fetch("https://api.worthypayments.com/v1/charges", {
method: "POST",
headers: {
"x-api-key": process.env.WP_SECRET_KEY,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
},
body, // the SAME string that was signed
});
```
Python (same string: timestamp, nonce, raw body, joined by dots):
```python
import hashlib, hmac, json, os, time, uuid, urllib.request
body = json.dumps({"amount": 4999, "currency": "usd"}, separators=(",", ":"))
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
secret = os.environ["WP_HMAC_SECRET"].encode()
signature = hmac.new(
secret, f"{timestamp}.{nonce}.{body}".encode(), hashlib.sha256
).hexdigest()
req = urllib.request.Request(
"https://api.worthypayments.com/v1/charges",
data=body.encode(),
method="POST",
headers={
"x-api-key": os.environ["WP_SECRET_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
},
)
```
`curl` cannot HMAC for you. Compute the hex the same way, then:
```
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: $WP_SECRET_KEY" \
-H "Idempotency-Key: $NONCE" \
-H "Content-Type: application/json" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIGNATURE" \
--data-binary @body.json
```
`--data-binary` keeps the exact bytes you signed. Pretty-printed JSON will not verify.
Failures return `401` with a specific code, so you can tell a clock problem from a
genuine mismatch:
| Code | Meaning |
|---|---|
| `hmac_headers_missing` | One or more of the three headers is absent |
| `hmac_invalid` | Signature does not match the body |
| `hmac_timestamp_invalid` | `X-Timestamp` is not an integer |
| `hmac_timestamp_skew` | More than 300s from server time. Check your clock |
| `hmac_nonce_reuse` | This nonce was already used within the last 300s |
| `hmac_secret_missing` | Signing is enabled on the key but no secret is provisioned |
> Never sign in the browser: signing requires the secret, and a secret in page source is
> a compromised secret. The [browser SDK](/developers/08-embedded-sdk.md) uses publishable keys,
> which are never signed.
========================================================================
# Charges
A charge collects a payment from a card. When you create a charge, WorthyPayments runs
**fraud screening, routing, and 3-D Secure inline**. There is no separate pre-screen
call. The single `POST /charges` request returns one of three outcomes: it **succeeded**,
it **requires action** (a 3-D Secure step-up), or it produced an **error**.
This page is the server-side charge contract: you build the request, you read the
response. If you are taking a card payment in a browser, use the
[Payment Element](/developers/08b-payment-element.md) instead. It creates the charge for you from a
payment intent, and your page never handles a card number or a token.
```
POST https://api.worthypayments.com/v1/charges
GET https://api.worthypayments.com/v1/charges/:id
```
Scope: `payments:charge` (create), `payments:read` (retrieve). Call it with a secret key
(`sk_`) from your server. Keep the `sk_` there: it is not a browser credential.
---
## Create a charge
`POST /charges`
### Request body
| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `amount` | integer | Yes | **Cents.** Minimum `50`. `$49.99` → `4999`. |
| `currency` | string | No | ISO 4217, e.g. `"usd"`. Defaults to `usd` when omitted. |
| `card` | object | one | A new card, as tokens. See [below](#card-object). |
| `payment_method` | string \| object | one | Id of a saved card, or a payment-method object, to charge instead of `card`. |
| `recurring` | boolean | No | `true` for a subscription renewal using a saved payment method. For saved cards, omitted or `false` means an unscheduled merchant-initiated charge. See [saved cards](#charge-a-saved-card). |
| `initiated_by` | string | No | Saved cards only: `"customer"` when the cardholder is paying now (on your checkout), `"merchant"` (the default) when you charge without them. See [saved cards](#charge-a-saved-card). |
| `wallet` | object | one | An Apple Pay / Google Pay credential. **Do not** also send `three_ds`; it is rejected. |
| `customer` | object | No | See [`customer`](#customer-object). |
| `billing_address` | object | No | See [`address`](#address-objects). Feeds AVS, fraud scoring, and 3-D Secure. |
| `shipping_address` | object | No | An address plus `name` and `phone`. |
| `device` | object | No | `{ fingerprint?, kount_session_id? }`. Improves scoring; **fails open** if missing. |
| `device_session` | string | No | A `ds_…` id from `wp.getDeviceSession()` in the buyer's browser. A charge your server sends then carries the buyer's IP and browser instead of your server's; without it, your server's address is recorded as yours and never counted as a buyer IP. Unknown or expired is a `400` with `param: "device_session"` that does not consume the `Idempotency-Key`. See [Device data collection](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser). |
| `three_ds` | object | No | `{ session_id?, request? }`, nothing else (another key is a 400). `session_id`: set it when resubmitting after a step-up. `request`: `automatic` (default), `any`, `challenge` or `challenge_mandated` — see [Requesting 3-D Secure](#requesting-3-d-secure). **Never** client `cavv`/`eci`; **never** with `wallet`. |
| `description` | string | No | Shown on the receipt and stored with the charge. It does not reach the buyer's card statement; that text is your merchant account's descriptor. |
| `capture_method` | string | No | `"automatic"` (default) or `"manual"`. See [Authorize & Capture](/developers/17-auth-capture.md). |
| `capture` | boolean \| string | No | Legacy alias: `false` / `"manual"` → manual capture. Prefer `capture_method`. |
| `risk_decision_token` | string | No | Signed receipt from a prior [`POST /check`](/developers/26-decision-token.md). Optional; **strict if present**; never pre-approves. |
| `metadata` | object | No | Your own key/value pairs, e.g. `{ "order_id": "1234" }`. Stored on the charge and returned on the create response, `GET /v1/charges/:id`, `GET /v1/charges` and the charge webhooks (`data.object.detail.metadata`). Up to 50 keys; a key is 1-40 characters without square brackets; a value is a string of up to 500 characters (a number or boolean is stored as its text; an empty value stores no key). Anything else is `400 parameter_invalid`, `param: "metadata"`. Cleared if the customer is deleted (GDPR), like the billing details. |
one Provide **exactly one** instrument: `card`, `payment_method`, or `wallet`. Sending a
wallet alongside either card field is a `400` rather than a guess. A wallet payment that
carries a cryptogram (Apple Pay, Google Pay on a device token) already satisfies SCA, so
`three_ds` with `wallet` is refused. A Google Pay card returned without one (`PAN_ONLY`)
does not, and is charged, and can be stepped up by your rules, like any card.
> **Unknown top-level fields are `400 parameter_unknown`, not ignored.** The body is
> allowlisted. Live-verified refusals on `POST /v1/charges`: | You sent | Use on this
> route | |---|---| | `email` / `receipt_email` | `customer.email` | | `billing` |
> `billing_address` | | `order_reference` | `description` and/or `metadata`, create has no
> `order_reference` | Those names **are** valid elsewhere: `receipt_email` and
> `reference` on `POST /v1/payment_intents`; `billing` (not `billing_address`) on confirm.
> Copy-pasting a Stripe charge body, or an intent body, into this endpoint is the usual
> first 400.
> **`amount` is required. Provide exactly one instrument (`card`, `payment_method`, or
> `wallet`). Everything else is optional**, including `currency` (defaults to `usd`). Send
> as much customer/address detail as you have: the more you send, the better AVS, fraud
> scoring, and 3-D Secure perform.
> **`risk_decision_token` never pre-approves a charge.** Whether or not you send one, this
> endpoint runs the full fraud engine on the data the charge actually carries. The token
> only lets a mismatch be *rejected*, when the amount, card or device differs from the
> evaluation it came from. See [Risk decision token](/developers/26-decision-token.md).
#### `card` object
There are two tokenization rails, and a card charge uses exactly one of them.
**`ev:` rail.** Separate tokens in `number` and `cvv`. Required for 3-D Secure: the
step-up consumes this rail.
| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `number` | string | Yes | An `ev:…` token. A raw PAN is rejected. |
| `cvv` | string | Yes | An `ev:…` token. The field is **`cvv`** everywhere, including the `POST /test/tokens` response, nothing to map. |
| `exp_month` | string \| number | Yes | Two digits, e.g. `"12"`. Required on the `ev:` rail (expiry is not inside the PAN token). |
| `exp_year` | string \| number | Yes | Two or four digits, e.g. `"30"` or `"2030"`. |
**`wp:` rail.** One `wp:v1:` envelope in `wp_token`, with PAN, expiry and CVV sealed
together. There is no `wp:` cvv field, because the CVV is already inside the envelope.
| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `wp_token` | string | Yes | A `wp:v1:` envelope. Leave `number` and `cvv` unset. |
| `exp_month` | string \| number | No | Optional on this rail. Expiry is sealed inside the envelope; the edge accepts omission (defaults to `0` on the wire meta). |
| `exp_year` | string \| number | No | Optional on this rail for the same reason. |
Both rails share the rest of the object:
| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `name` | string | No | Cardholder name. |
| `bin` | string | No | First 6 to 8 digits of the PAN (never the full PAN). Used for routing and fraud. |
| `last4` | string | No | Last 4 digits. |
> **The PAN never reaches WorthyPayments in the clear.** Tokenize before you charge; a raw
> card number is rejected with `raw_pan_rejected`. See
> [Card tokenization](/developers/28-card-tokenization.md).
> **`wp:` tokens are single use and expire in 10 minutes.** Tokenize immediately before
> charging, and mint a fresh token for every attempt, including a retry after a decline,
> which has already spent the previous one. `ev:` tokens have neither limit.
#### Charge a saved card
For renewals and other merchant-initiated charges, call `POST /v1/charges` with
`payment_method.payment_method_id`. Use the saved card's `pm_` id (a bare UUID is
also accepted). Set `recurring: true` for a subscription renewal:
```json
{
"amount": 2000,
"currency": "usd",
"payment_method": {
"payment_method_id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461"
},
"recurring": true
}
```
A saved-card charge is a subsequent merchant-initiated transaction (MIT) unless you say
otherwise. Omit `recurring` or set it to `false` for an unscheduled MIT. The field accepts
only a boolean; `true` with a fresh card, wallet, `wp_token` or ACH is
`400 parameter_invalid` with `param: "recurring"`. Use a new idempotency key for each
renewal. Changing `recurring` with the same key is an `idempotency_error`; omitted and
`false` are equivalent.
**Retrying a declined renewal is yours.** When your own system schedules renewals (your
subscription plugin, your billing job) and charges them with `POST /v1/charges`, we never
retry a decline on our own: the charge is declined once and your retry rules decide what
happens next. Send each retry as a new charge with a new idempotency key. Our automatic
decline recovery runs only on charges we schedule ourselves with no buyer present: renewals
of our [subscriptions](/developers/18b-subscriptions.md) and invoice charges. A saved card the buyer
chooses at the hosted checkout or in the Payment Element is not retried later either: the buyer
sees the decline and can pay another way.
**The customer paying now with a saved card.** When the cardholder is on your checkout and
picks a card they saved earlier, send `"initiated_by": "customer"`. The card networks expect
that charge flagged as customer-initiated on a stored credential, not as a merchant charge.
`"merchant"` (or omitting the field) keeps the MIT above, and is accepted with
`recurring: true`. `initiated_by` is refused with `400 parameter_invalid`,
`param: "initiated_by"`, on a fresh card (always customer-initiated), as `"customer"` with
`recurring: true` (a renewal is merchant-initiated), or with any other value. Changing it
with the same idempotency key is an `idempotency_error`; omitted and `"merchant"` are
equivalent. The Payment Element and hosted checkout already send saved-card payments as
customer-initiated.
A customer-initiated saved-card charge is a buyer paying now, so your 3-D Secure rules
apply to it exactly as in the Payment Element: the route's **Attempt 3DS**, the 3DS risk
band, a customer marked **Require 3DS**, a fraud rule asking for 3DS, and your own
`three_ds.request`. When one asks, the card is authenticated on the encrypted copy kept when
it was saved: the charge answers [`requires_action`](#2-requires-action-3-d-secure), you
run the challenge in the buyer's browser and resubmit with `three_ds.session_id` under the
same `Idempotency-Key`. A card with no such copy (saved before 2026-10-18, or imported from
your gateway vault) is declined `soft_decline_3ds` with nothing charged: ask the buyer to
enter the card again ([3DS on a saved card](/developers/07-3ds.md#3ds-on-a-saved-card)).
The charge that **saves** a card (the buyer ticks "save this card" in the Payment Element or
hosted checkout) is sent as the initial customer-initiated transaction that stores the
credential. Every later charge on that card, customer- or merchant-initiated, references
it, as the card networks expect; you do not pass anything for this. A card saved before
that reference was kept, or added without a payment, has none, and its charges are sent
without it.
Merchant-initiated charges (`recurring`, `"initiated_by": "merchant"` or the field
omitted) are never sent to 3-D Secure: there is no cardholder to complete a challenge, and
card networks exempt merchant-initiated charges. A customer flagged
**Require 3-D Secure**, the 3DS risk band, a fraud rule asking for 3DS, or the route's
**Attempt 3DS** does not decline them; they are charged without 3DS. A fraud rule that
blocks still blocks them.
Installments are not supported. Transaction reporting classifies MITs broadly as recurring;
it does not distinguish unscheduled MITs from subscription renewals. See
[Customers & saved cards](/developers/18-customers.md#charge-a-saved-card) for saving and looking up cards.
#### `customer` object
| Field | Type | Notes |
|-------|------|-------|
| `email` | string | A valid email address. |
| `name` | string | Full name. If `first_name`/`last_name` are absent it's split on the last space. |
| `first_name` | string | Takes precedence over `name`. |
| `last_name` | string | |
| `phone` | string | |
#### `address` objects
Both `billing_address` and `shipping_address` use these fields. `shipping_address` adds
`name` and `phone`.
| Field | Type | Notes |
|-------|------|-------|
| `line1` | string | Street address. |
| `line2` | string | Apartment, suite, etc. Folded into the AVS line. |
| `city` | string | |
| `state` | string | State / province. |
| `postal_code` | string | ZIP / postcode. Used for AVS. |
| `country` | string | ISO 3166-1 alpha-2, e.g. `"US"`. |
| `name` | string | *(shipping only)* recipient name. |
| `phone` | string | *(shipping only)* |
### Example
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: ord_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"card": {
"wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g...",
"exp_month": "12",
"exp_year": "2027",
"bin": "424242",
"last4": "4242",
"name": "Jane Doe"
},
"customer": { "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe" },
"billing_address": {
"line1": "510 Townsend St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94103",
"country": "US"
},
"device": { "fingerprint": "tp_fp_9f2c..." },
"description": "Order #1234",
"metadata": { "order_id": "1234" }
}'
```
A successful charge echoes the `customer`/`billing_address`/`shipping`/`metadata` you sent
back under `billing_details`, `shipping`, and `metadata`, so your script can read them
straight off the **create** response.
> ⚠️ **A read returns less than the echo.** `GET /v1/charges/:id` returns `billing_details`
> from what we stored: the customer's name, email and phone, and the billing address with
> `line2` folded into `line1`. It returns your `metadata` as stored, and no `shipping`,
> because the transaction record does not carry it; keep your own copy of the shipping
> address, keyed on the returned `id`, if you need it later. The card (`last4`) also
> survives a read. A charge created before metadata was stored reads `metadata: {}`.
> **Send a stable [`Idempotency-Key`](/developers/12-idempotency.md) header on every charge**
> (one key per logical order, not a fresh UUID on every Pay click). If a retry fires
> after a network blip, it replays the original result instead of charging twice.
### Fields you must get right (summary)
Full list: [Integration hard rules](/developers/27-integration-hard-rules.md).
| Topic | Rule |
|---|---|
| **PAN** | Tokens only (`ev:` or `wp:v1:`); raw digits rejected |
| **IP** | Not a body field on charge; see [below](#client-ip-fraud-signal-not-a-body-field) |
| **3DS** | Resubmit only `three_ds.session_id`; never client cryptograms |
| **Idempotency-Key** | Required; **stable per order**, not a new UUID every click |
| **`risk_decision_token`** | Optional; strict if present; **never pre-approves** the charge |
| **Wallet + 3DS** | Do not combine; rejected |
| **Device** | Fingerprint / Kount improve scoring; missing fails open (sale still runs) |
| **Decline UI** | Show a generic message, never raw fraud reasons to the buyer |
### Client IP (fraud signal, not a body field)
**There is no `ip` field on `POST /charges`.** We set `ipAddress` from the **TCP peer**
that hit our edge (behind the ALB: the hop our load balancer appended to
`X-Forwarded-For`). A value in the JSON body is **ignored** even if you send one.
That is deliberate: IP drives per-IP velocity, the IP blocklist, geo gates and cascade.
A caller-supplied address would let an attacker rotate a string and defeat all of them.
Calling this endpoint from your server with a secret key (`sk_…`), the connection is **your
server**. We record that address as your server's and never as a buyer IP: it is not scored,
never blocklisted, and a card-testing burst from one buyer cannot get your server blocked.
The charge then has no buyer IP to score on unless you send a
[`device_session`](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
created in the buyer's browser, which gives it the buyer's IP and browser. A renewal has no
buyer at a browser, so it is charged without one.
The [Payment Element](/developers/08b-payment-element.md) does not pay that cost. The browser
confirms the intent directly, so the peer we record is the buyer, with nothing for you to
pass through.
If your backend proxies the charge, do **not** invent an IP field on this endpoint. It
will not be read. For a pre-charge evaluation that must see the real shopper IP, call
[`POST /v1/check`](/developers/23-fraud-checks.md) from your server and set **`ip` to the buyer’s
address** (see that guide). The charge still re-runs the full engine; check never
pre-approves money movement.
**How to get the buyer IP on your side (when proxying `/check`):** take the address your
trusted reverse proxy recorded for the shopper (typically the rightmost hop *your*
infrastructure appended to `X-Forwarded-For`, or the equivalent on CloudFront / Cloudflare).
Never forward a raw client-supplied header as-is without stripping untrusted left-hand
entries. Never use your own server’s public IP as a stand-in for the buyer.
---
## Responses
A charge returns exactly one of these three shapes. Branch **only** on these outcomes
(plus HTTP status for hard errors).
### 1. Succeeded
```json
{
"id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"object": "charge",
"status": "succeeded",
"amount": 4999,
"currency": "usd",
"card": { "last4": "1111", "brand": "Visa" },
"billing_details": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": null,
"address": { "line1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postal_code": "94103", "country": "US" }
},
"shipping": null,
"metadata": { "order_id": "1234" },
"created": 1733600000
}
```
The payment is captured. `created` is a Unix timestamp (seconds). `billing_details`,
`shipping`, and `metadata` echo what you sent.
> **Reading a charge back returns less than creating one.** `GET /v1/charges` and `GET
> /v1/charges/{id}` return `card`, `billing_details` and `metadata` from what we stored, so
> the brand, last4, BIN and your metadata are there, but `shipping` is **not**: it exists on
> the create response only, as an echo of your request. If you need it later, keep it on
> your side.
> Absent values are omitted from the JSON rather than sent as `null`, so use optional
> chaining instead of comparing against `null`.
**Wallet charges.** `card.wallet` is `{ "type": "apple_pay" }` or `{ "type": "google_pay" }`
when the charge was an Apple Pay / Google Pay payment we processed (a `wallet` block, the
Payment Element or hosted checkout), on the create response and on every read. An approved
live sale synced from your NMI gateway carries it too, once NMI has named its wallet: a new
sale usually a few minutes after it syncs, one synced before this existed when a one-off
history check reaches it. No webhook is sent when it arrives; read it back with
`GET /v1/charges/{id}` or `GET /v1/charges`. A declined or test-mode synced sale never carries
it. On a wallet charge
`card.last4` and `card.bin` belong to the **device token**, not the buyer's physical card, so
do not match a receipt or a dispute on them alone. `card.wallet` is `null` otherwise: we
never guess a wallet.
`card.wallet.card_last4` and `card.wallet.card_brand` name the buyer's **own card** inside
the wallet: the last four digits and network the wallet sheet stated beside the device
token, which is the number the buyer's bank and an issuer's dispute alert use. Both are
`null` when the wallet did not state them, and on a sale synced from your gateway, which
names the wallet but not the card inside it. On `POST /v1/charges` you send them as
`wallet.card_last4` / `wallet.card_brand` (the SDK's wallet credential carries them); they
are optional and never cause a `400`, since an unusable value is dropped. Neither wallet
hands out a BIN.
### 2. Requires action (3-D Secure)
A step-up is required. Authentication can precede charge creation, so `requires_action`
**has no charge `id` until authentication completes**. A real existing transaction id, if
one was returned, is preserved.
```json
{
"object": "charge",
"status": "requires_action",
"next_action": {
"type": "three_ds_challenge",
"three_ds": {
"session_id": "tds_visa_439ab3fc28d1"
}
}
}
```
Run the challenge, then **resubmit the same charge** with `three_ds.session_id` set to
`next_action.three_ds.session_id`. Same amount, same currency, same instrument, plus that
one field. **Use the same `Idempotency-Key`** as the first request; `requires_action` is
not stored as a terminal response.
You do not build the cryptogram yourself. WorthyPayments retrieves the authoritative
cryptogram server-side from the session, and a client-supplied one is never trusted. Full
walkthrough: [3-D Secure](/developers/07-3ds.md). With the [Payment
Element](/developers/08b-payment-element.md) this outcome never reaches you, because the frame
handles the step-up and the resubmit.
With the buyer paying (a payment intent, the hosted checkout, or your server's charge on a
saved card with `"initiated_by": "customer"`), a saved card that needs 3DS is authenticated
on the encrypted copy kept when it was saved: this `requires_action`, then the resubmit with
the same body plus `three_ds.session_id`. One saved before 2026-10-18 or imported, with no
such copy, is declined with HTTP 402 `card_error` / `card_declined` and
`decline_code: soft_decline_3ds`, nothing charged; the Payment Element then asks the buyer to
re-enter it, and your own checkout should do the same
([3DS on a saved card](/developers/07-3ds.md#3ds-on-a-saved-card)). A merchant-initiated saved-card
charge (a renewal, `"initiated_by": "merchant"` or omitted) is never stepped up. Authenticated
payments, ACH and wallet payments that carry a cryptogram are not stepped up either. A deterministic 3DS provider rejection uses `processing_error`;
a temporary provider or session-storage failure returns a sanitized 503 `api_error`,
which can be retried with the same key. An unusable challenge response returns 502
`api_error` ("3-D Secure could not be started").
**A session that did not authenticate never charges.** The resubmit charges only when the
session authenticated with a cryptogram (fully, or an "attempted" authentication that
carries one). Otherwise nothing reaches the card network:
| Session | Response | What to do |
|---|---|---|
| Failed | 402 `card_error` / `card_declined`, `decline_code: three_ds_failed`, `param: three_ds.session_id` | The cardholder failed or canceled the challenge, or the issuer refused. Ask them to try again or use another card, under a **new** `Idempotency-Key` (this refusal is stored like any decline). |
| Card not enrolled | 402, `decline_code: three_ds_card_not_enrolled` | The card cannot be authenticated. Charge it again without `three_ds` if you accept no liability shift, or ask for another card. |
| 3DS network fault | 402, `decline_code: processing_error` | Start a new attempt; it opens a new session. |
| Still open | 409 `three_ds_session_incomplete` | The challenge has not finished. Resubmit with the **same** key once it does. |
| Result unreadable | 503 `three_ds_result_unavailable` | Retry with the same key. |
`GET /v1/3ds/sessions/{id}` names the exact reason in `failure_reason`.
#### Requesting 3-D Secure
Your fraud rules and the route's Require 3DS decide when a charge is stepped up. To run 3DS
on a charge whatever they decide, send `three_ds.request`:
```json
{
"amount": 4999,
"currency": "usd",
"card": { "number": "ev:…", "cvv": "ev:…", "exp_month": 12, "exp_year": 2030 },
"three_ds": { "request": "challenge" }
}
```
| `three_ds.request` | 3DS runs | The issuer is told (EMV 3DS challenge indicator) |
|---|---|---|
| `automatic` (default) | when your rules ask | a rule-driven challenge is mandated (04) |
| `any` | always | no preference (01): frictionless whenever the issuer allows |
| `challenge` | always | a challenge is requested (03); the issuer may still go frictionless |
| `challenge_mandated` | always | a challenge is mandated (04) |
The charge then returns `requires_action` and continues exactly as above; repeating
`request` on the resubmit is fine. Two rules:
- **Your rules outrank a request.** When they ask for 3DS, the challenge is mandated
whatever `request` says, so `any` cannot soften a challenge your risk rules demanded.
- **It needs a card the buyer is paying with now**: a card entered for this payment
(`card.number` as an `ev:` token), or a saved card sent with `"initiated_by": "customer"`.
On a merchant-initiated saved-card charge, a gateway vault id, a `wp:` envelope or a
wallet it is a 400 `parameter_invalid` with `param: three_ds.request`. Omit it to charge
those as before.
On a [payment intent](/developers/08b-payment-element.md) you set the same field at create; the
Payment Element runs the challenge.
### 3. Error (declined or invalid)
```json
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "The charge was declined."
}
}
```
See the [error envelope](/developers/10-errors.md) for the full `type` / `code` map.
A refusal by the payment gateway itself (it never reached the card network, for example a
duplicate of a payment that just went through) arrives in this same shape, at once, with
`decline_code` `duplicate_transaction`, `invalid_merchant`, `invalid_transaction` or
`processing_error` and the gateway's own text in `message`. Never retry
`duplicate_transaction`: look the payment up first. See [Gateway
refusals](/developers/10-errors.md#gateway-refusals).
A charge our fraud screening blocks is not in this shape: it is a `400`
`failed_precondition` whose `message` starts `FRAUD_BLOCKED`, decided before any 3-D Secure
step-up. A block by the scored fraud screen is recorded as a failed charge, and the 400
names it in `charge` (read it back below: `outcome.type: blocked`). See [Fraud
blocks](/developers/10-errors.md#fraud-blocks-fraud_blocked).
> **`pending` on create.** When the gateway has not told us in time whether the card was
> charged, the create answers `status: "pending"`. That answer is **not** stored under your
> `Idempotency-Key`: retry with the same key to learn the outcome, never with a new one. See
> [Idempotency](/developers/12-idempotency.md#a-pending-charge-is-not-a-final-answer).
### Tokenization errors
Separate from declines: these mean the charge was never attempted, because the card token
itself was not usable. All are `400 invalid_request_error`.
| `code` | Cause | Retry? |
|--------|-------|:---:|
| `wp_token_expired` | Token older than 10 minutes | Re-tokenize |
| `wp_token_replayed` | Token already used on a previous charge | Re-tokenize |
| `wp_token_malformed` | Token failed to decrypt or validate | no, fix the integration |
| `wp_token_audience_mismatch` | Token minted for a different account | no, check your keys |
| `wp_token_too_large` | Token over 512 characters | no, remove extra envelope fields |
| `raw_pan_rejected` | A plain card number was sent | no, tokenize first |
| `wp_replay_store_unavailable` | Our outage, not your token | yes, retry the same request |
A retired key id also arrives as `wp_token_malformed`. There is no separate code for it,
because `kid` lives inside the sealed envelope and reporting it apart would leak whether a
token merely failed to decrypt. One refetch-and-retry covers it.
Do not surface these to the shopper as declines. The card is not the problem. Full
causes and fixes: [Error codes](/developers/30-error-codes.md).
> **Buyer-facing copy:** always show a generic “payment could not be processed” (or your
> own brand wording). Do **not** surface issuer fraud text, risk scores, or reason codes
> in the checkout UI. That helps attackers probe your controls.
---
## Decline codes
When a charge is declined, `error.type` is `card_error`, `error.code` is the high-level
reason, and `error.decline_code` (when present) is the issuer's specific reason. Common
values:
| `code` | `decline_code` | Meaning | What to do |
|--------|----------------|---------|------------|
| `card_declined` | `insufficient_funds` | Not enough balance | Ask for another card |
| `card_declined` | `do_not_honor` | Generic issuer decline | Ask for another card |
| `card_declined` | `pickup_card` | The issuer asked for the card to be retained (a card reported lost or stolen comes as `lost_card` / `stolen_card`) | Do not retry; ask for another card |
| `card_declined` | `fraudulent` | Issuer suspects fraud | Do not retry this card |
| `card_declined` | `processing_error` | Transient issuer or gateway error; nothing was charged | Retry after a short backoff, with a new `Idempotency-Key` |
| `card_declined` | `duplicate_transaction` | The gateway refused it as a duplicate of a recent identical payment | **Do not retry.** Check `GET /charges` first |
| `card_declined` | `three_ds_failed` | The `three_ds.session_id` session did not authenticate; nothing was charged | Let the customer retry 3DS or use another card, under a new `Idempotency-Key` |
| `card_declined` | `three_ds_card_not_enrolled` | The card cannot be authenticated with 3-D Secure; nothing was charged | Charge without `three_ds` (no liability shift) or ask for another card |
| `card_declined` | `invalid_merchant` / `invalid_transaction` | The gateway refused the request (account setup, or a transaction it does not allow) | Do not retry; contact support |
| `expired_card` | — | Card expired | Ask for updated expiry / new card |
| `incorrect_cvc` | — | CVV mismatch | Ask the shopper to re-enter the CVV |
> Decline codes are issuer-driven and the list is not exhaustive. Always render
> `error.message` to the shopper and branch your retry logic on `error.code`. See
> [Errors](/developers/10-errors.md) for retry guidance.
---
## Retrieve a charge
`GET /charges/:id`. Requires `payments:read`.
```bash
curl https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3 -H "x-api-key: sk_live_xxx"
```
```json
{
"id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"object": "charge",
"amount": 4999,
"currency": "usd",
"status": "succeeded",
"processor_transaction_id": "1234567890",
"auth_code": "123456",
"paid": true,
"captured": true,
"amount_refunded": 1000,
"card": { "last4": "1111", "brand": "Visa" },
"metadata": { "order_id": "1234" },
"created": 1733600000,
"processor": "NMI",
"processor_label": "NMI (Worthy)",
"mid": "9876543",
"network_transaction_id": "024attmpn7fk3d0",
"response_code": "100",
"response_message": "Approved",
"processor_response_code": "100",
"processor_response_text": "SUCCESS",
"avs_result": "MATCH",
"avs_result_code": "Y",
"cvv_result": "MATCH",
"cvv_result_code": "M",
"statement_descriptor": "WORTHY* STORE",
"payment_method_type": "card",
"order_reference": "SO-1042",
"authorized_at": "2026-08-31T09:15:00Z",
"captured_at": "2026-08-31T09:15:01Z"
}
```
### Which acquirer ran it, and what the gateway said
`processor` is not invented and it is not a fixed `"NMI"`. At charge time **Payment
Workflow** picks the acquirer from the routes on that road, BIN, amount, fraud, cascade,
whatever those rules say *for this sale*. Those processors all ride the merchant's **one
NMI security key**. What changes per acquirer is the NMI `processor_id` on the sale: Admin
→ Acquirers → **Connector code (`processor_id`)** / charge code of the row Workflow just
chose (`tsysemv`, `ccprocessora`, `nuveidigitalpaym`, …). After the sale, public
`processor` is that same row's **Code**.
That admin row has three distinct fields; mixing them up is the usual mistake:
| Admin field | What it is | After the sale |
|---|---|---|
| **Code** | The acquirer's registry identity. Uppercase, as configured: `NMI`, `PAYARC`, `NUVEI`, `MAVERICK`, or any later row. | Public `processor` on `GET /v1/charges/{id}` and `charge.succeeded`. The acquirer Workflow landed on. |
| **Connector codes** | Raw gateway keys that map a MID or sync traffic onto this acquirer (aliases the gateway may report). | Used to *recognise* the acquirer. Never returned as `processor`. |
| **Connector code (`processor_id`)** / charge code | Charge-time NMI `processor_id` on that row (`tsysemv`, `ccprocessora`, `nuveidigitalpaym`, …). | Sent on the sale so NMI posts to that connector, under the same security key. Not the public `processor` field. |
A sale Workflow sent to PAYARC therefore charges with PAYARC's charge code and answers
`processor: "PAYARC"`. The next sale, on different conditions, can land on NUVEI the same
way. The retrieve example above uses `"NMI"` only because that retrieve landed on an NMI
route, copy the value the registry stamped, do not hardcode it.
The rest of the block after `created` is the gateway's own answer, stored when it came
back. Those columns are reflected, not computed.
| Field | Meaning |
|---|---|
| `processor` / `processor_label` | Which **acquirer** ran the sale, from the table above. `processor` = Admin → Acquirers → **Code**. `processor_label` is that MID's display name; omitted when empty. |
| `mid` | The merchant id (MID number) the sale ran on. Same value the webhook publishes as `mid`. Omitted when the row has none (common on a test-mode default MID). |
| `processor_transaction_id` | The gateway's own id for the transaction (NMI `transactionid`). Also on the Payment Element receipt as `charge.processor_transaction_id`. |
| `network_transaction_id` | The card network's id. Keep it: a card-on-file sequence references the first payment by this. |
| `response_code` / `response_message` | The gateway's result. `100` is an approval on NMI. |
| `processor_response_code` / `processor_response_text` | The processor's verbatim pair, when it differs from the gateway's. |
| `avs_result` / `cvv_result` | Address and security-code verdicts, `MATCH`, `NO_MATCH`, `NOT_PROVIDED`… |
| `avs_result_code` / `cvv_result_code` | The raw issuer letters behind those verdicts (`Y`, `M`, `N`…). |
| `statement_descriptor` | The descriptor stored with this charge, if any. The text printed on the buyer's statement is set by your merchant account and can differ. |
| `authorized_at` / `captured_at` / `voided_at` / `refunded_at` / `settled_at` | When each state actually happened. |
| `capture_before` | On an authorized, uncaptured charge: the last moment it can be captured (4 days 18 hours for a Visa merchant-initiated charge, 7 days otherwise; see [Authorize and capture](/developers/17-auth-capture.md)). Past it the charge is canceled. Stripe's `payment_method_details.card.capture_before`. |
| `cancellation_reason` | `expired` on a charge canceled because nobody captured it before `capture_before`. Absent on a void you requested. |
**Every one of them is omitted when the row has nothing in it**. An absent fact and an
empty string are different answers.
### `outcome`: who answered
Every charge read carries an `outcome` object, always present:
| Charge | `outcome.network_status` | `outcome.type` | `outcome.seller_message` |
|---|---|---|---|
| succeeded / requires capture | `approved_by_network` | `authorized` | `Payment complete.` |
| failed: the issuer or network declined it | `declined_by_network` | `issuer_declined` | `The bank declined this payment.` |
| failed: the gateway refused it before the card network saw it | `not_sent_to_network` | `invalid` | `The payment was not processed: ` + the gateway's text (`The payment was not processed.` when it gave none) |
| failed: our fraud screening blocked it before any gateway call | `not_sent_to_network` | `blocked` | `This payment was blocked by fraud screening.` |
| pending | `pending` | `pending` | `Payment is processing.` |
| canceled | `not_sent_to_network` | `invalid` | `The payment was canceled.` |
`outcome.risk_level` is `normal`, except on a fraud block, where it is `highest`. Tell a
gateway refusal from an issuer decline by `network_status`, never by the decline code:
`processing_error` is also an issuer answer. `GET /v1/charges/{id}` and `GET /v1/charges`
report the same `outcome` for the same charge.
**A fraud-blocked charge.** The one a scored fraud block's `POST /v1/charges` 400 names in
`charge` reads `status: failed`, `failure_code: fraudulent`, `failure_message: "Your card
was declined."` and `outcome.type: blocked`; the fraud score and decision are on `GET
/v1/charges/{id}/risk`. A charge the fraud pre-screen (velocity) refused is listed too and
now reads the same `outcome`, but keeps the pre-screen's own `failure_code`, not
`fraudulent`; an older one still reads `issuer_declined`.
⚠️ **`arn`, `rrn` and `settlement` are not there on a fresh charge, and that is correct.**
They come from the acquirer days later, so a just-authorized charge has nothing to report.
Read them back on the same endpoint after settlement rather than expecting them at
payment time.
> **AVS and CVV are the gateway answering; your fraud score is us judging.** These two
> verdicts arrive inside the reply to this sale, so they live here. Your own engine's
> score, decision, reason codes and case id are a different question and are served by
> `GET /v1/charges/{id}/risk` behind `payments:read`. See the [API
> reference](https://app.worthypayments.com/developers/api#tag/charges).
> **Not on the browser payload.** The Payment Element's `onSuccess` is a named receipt
> subset: `amount`, `currency`, `status`, `paid`, `captured`, `amount_captured`,
> `amount_refunded`, `auth_code`, **`processor_transaction_id`** (the gateway id / NMI
> `transactionid`), `statement_descriptor`, `description`, `payment_method_type`,
> `metadata`, `created`, `billing_details`, `card` (`brand`, `last4`, `exp_month`,
> `exp_year`), and `failure_code` / `failure_message` on a decline. `mid`, `processor`,
> `processor_label`, `network_transaction_id`, `arn`, `rrn`, settlement, and the AVS/CVV
> verdicts stay on `GET /v1/charges/{id}` behind your secret key. That payload lands in a
> page the buyer can read. See [Payment
> Element](/developers/08b-payment-element.md#what-comes-back).
### Which processors it was tried on: `expand[]=attempts`
A charge can be tried on more than one processor. When the first one soft-declines,
Payment Workflow retries the same payment on the next processor in the route's cascade:
codes `200`, `201`, `202`, `260`, `05`, `51`, `61`, `65` and `N7` are retried, `203`, `204`
and `250` never are, and a wallet (Apple Pay, Google Pay) or `wp:` token charge gets one
attempt only. `processor` above names where the charge ended; to see every attempt, ask
for them:
```bash
curl "https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3?expand[]=attempts" \
-H "x-api-key: sk_live_xxx"
```
```json
{
"id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"object": "charge",
"status": "succeeded",
"processor": "MAVERICK",
"attempts": [
{
"processor": "PAYARC",
"processor_label": "Payarc Main",
"outcome": "declined",
"reconciled": false,
"decline_code": "insufficient_funds",
"response_code": "202",
"cascade": false,
"final": false,
"attempted_at": "2026-10-19T09:15:00.120Z",
"duration_ms": 210
},
{
"processor": "MAVERICK",
"processor_label": null,
"outcome": "approved",
"reconciled": false,
"decline_code": null,
"response_code": "100",
"cascade": true,
"final": true,
"attempted_at": "2026-10-19T09:15:00.400Z",
"duration_ms": 95
}
]
}
```
(The rest of the charge is as in [Retrieve a charge](#retrieve-a-charge) and is left out
here.) Attempts are listed in the order they were sent. Every key is present on every
attempt; `null` means we do not know it.
| Field | Meaning |
|---|---|
| `processor` / `processor_label` | The processor the attempt ran on, in the same words as the charge's own `processor` / `processor_label`. Never an internal id. |
| `outcome` | `approved`, `declined`, `error` (the gateway refused the request or answered something unusable), `timed_out` (no answer came back) or `pending` (still in flight). |
| `reconciled` | `true` when the attempt's own answer was lost and its approval was then confirmed by looking the sale up at the gateway. `outcome` is then `approved`. A lost answer is never turned into a decline this way. |
| `decline_code` | The normalized decline on a declined attempt, the same vocabulary as `failure_code`. |
| `response_code` | The gateway's code for this attempt, the same vocabulary as the charge's `response_code` (`100` approved, `202` insufficient funds…). `null` when no answer came back. |
| `cascade` | `true` on every attempt after the first: Payment Workflow retried the payment there. |
| `final` | The attempt that produced the charge's result. Always the last one. |
| `attempted_at` / `duration_ms` | When the attempt was sent (RFC 3339) and how long it took to get its answer. |
What to expect on older and unusual charges:
- **One attempt, one entry.** A charge that was not retried answers a one-entry list.
- **A fraud-blocked charge answers `[]`.** Our fraud screening refused it before any
processor saw it, so there was no attempt.
- **A charge made before attempts were published** lists its attempts without
`processor_label` or `attempted_at`, and with a `decline_code` on the final attempt
only: those were not recorded then.
- **A charge with no recorded trail** (made before 2026-07-31, or a sale synced from your
gateway) answers the one attempt its own record proves. If it was reached by a cascade,
that attempt says `cascade: true`; the earlier ones were not kept and are not listed.
`attempts` is only there when you ask. Without `expand[]` the charge is unchanged, and an
`expand[]` value this endpoint does not have is ignored, not refused. `expand[0]=attempts`
and `expand=attempts` work too. It is not offered on `GET /v1/charges`; retrieve the charge
you want to look into. It needs `payments:read` on a secret or restricted key, and a
publishable key never sees it. The `charge.*` webhooks do not carry it: fetch the charge
named in the event with `expand[]=attempts`.
### `amount` is not what settled
⛔ **`amount` is the amount the charge was created for, and it never changes.** Refunding a
charge does not reduce it, and a partial capture does not either, **including in the
capture response itself**, which returns the charge as it now stands rather than the
gateway's reply to the capture. A 1000 capture against a 2500 authorization answers
`amount: 2500`, `amount_captured: 1000`, and carries the same `auth_code` and
`processor_transaction_id` a retrieve would. Two further fields carry what actually moved:
| Field | Meaning | When present |
|---|---|---|
| `amount_refunded` | Cents refunded so far, across every partial refund | Once anything has been refunded |
| `amount_captured` | Cents actually captured, differs from `amount` only after a **partial** capture | Retrieve and capture |
Both are **omitted rather than zeroed** when there is nothing to report, so
`"amount_refunded" in charge` distinguishes "never refunded" from a genuine `0`.
`amount_captured` is returned by retrieve but **not by list**. The list row does not carry
it, so fetch the charge if you need it.
⛔ **A fully refunded charge still reads `status: "succeeded"`, `paid: true`.** That is
deliberate (the charge did succeed; the refund is a separate event) but it means status
alone must never gate fulfilment. Compare `amount_refunded` against `amount`, or list
[`GET /v1/refunds?charge=…`](/developers/06-refunds.md#list-refunds). Note that `GET
/v1/charges?status=refunded` matches only **fully** refunded charges; a partially refunded
one stays under `status=succeeded`.
`status` on create/confirm can be `succeeded`, `pending`, `requires_action`,
`requires_capture`, `canceled`, or `failed`. **Retrieve and list never return
`requires_action`:** a charge still waiting on 3-D Secure is remapped to `pending` on
reads so a GET never invents a `next_action` the caller cannot drive. Use the create or
confirm response (or the Element callbacks) for the step-up path.
A `resource_missing` (404) is returned if no charge with that id exists for your account.
## List charges
`GET /charges`. Requires `payments:read`. Cursor-paginated, newest first. It supports
`limit`, `starting_after`, `status` and `created`.
`created` takes a window in Unix **seconds**, `created[gte]`, `created[lte]`,
`created[gt]`, `created[lt]`, or a bare value for an exact match:
```bash
curl -G "https://api.worthypayments.com/v1/charges" \
-d "created[gte]=1733600000" -d limit=100 \
-H "x-api-key: sk_live_xxx"
```
⛔ It filters on **creation** time. A charge created last week and enriched an hour ago
does not reappear in a recent window, for "what changed", use [`GET
/v1/events`](/developers/19-events.md).
An unrecognized query parameter is silently ignored rather than rejected, so a mistyped
filter returns unfiltered data. See [Pagination](/developers/13-pagination.md).
To list one buyer's charges, use `GET /customers/:id/transactions`, `GET /charges` takes
no customer filter.
`?status=` accepts the **public** vocabulary you already see on charges
(`succeeded`, `failed`, `pending`, `requires_capture`, `canceled`, …) **and** the
legacy DB enums (`APPROVED`, `DECLINED`, `REFUNDED`, …). Prefer the public words so a
value you just read round-trips. An unknown status is **400**, not a silent no-op.
```bash
curl "https://api.worthypayments.com/v1/charges?limit=20&status=succeeded" -H "x-api-key: sk_live_xxx"
```
```json
{
"object": "list",
"data": [ { "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "amount": 4999, "currency": "usd", "status": "succeeded", "paid": true, "captured": true, "card": { "last4": "1111", "brand": "Visa" }, "metadata": {}, "created": 1733600000 } ],
"has_more": false
}
```
========================================================================
# Checkout Links
A checkout link is a WorthyPayments-hosted payment page. Create one server-side, redirect
the shopper to its URL, and the hosted page collects the card and runs the full engine
(fraud screening, routing, 3-D Secure) with **no card data ever touching your code**.
This is the redirect integration, and the lowest-PCI way to take a payment.
```
POST https://api.worthypayments.com/v1/checkout-links
GET https://api.worthypayments.com/v1/checkout-links
GET https://api.worthypayments.com/v1/checkout-links/{id}
```
Scopes: `checkout_links:create` to make one, `checkout_links:read` to list or fetch them
back. Both are **server-only**. A publishable key (`pk_`) cannot reach either; use a
secret key (`sk_`). See [Authentication](/developers/02-authentication.md).
There is no update or delete endpoint. See [Changing the price](#changing-the-price).
---
## The redirect flow
```mermaid
sequenceDiagram
autonumber
participant S as Your server
participant W as WorthyPayments
participant B as Shopper's browser
S->>W: POST /checkout-links
W-->>S: { url }
S->>B: Redirect the shopper to url
B->>W: Enters card, completes 3DS
Note over W: Charge runs: fraud, routing, 3DS
W-->>B: Hosted page completes
B->>S: Shopper returns after payment
```
---
## Create a checkout link
`POST /checkout-links`
### Request body
| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `name` | string | Yes* | Shown on the hosted page (e.g. `"Order #1234"`). *Or provide `description`. One of the two must yield a non-empty name. |
| `amount` | integer | one | **Cents.** For `FIXED` you almost always set this (minimum `50` in practice). Omit for shopper-driven `DONATION`. |
| `currency` | string | No | ISO 4217, e.g. `"usd"`. Defaults to `usd`. |
| `link_type` | string | No | `"FIXED"` (default), `"DONATION"`, or `"SUBSCRIPTION"`. |
| `plan` | string | SUBSCRIPTION | `plan_…` id. Required when `link_type` is `SUBSCRIPTION` (`400 parameter_missing` without it). Echoed on the **201 create body** and on GET, persist either. |
| `description` | string | No | Optional blurb; also used as the name fallback when `name` is empty. |
There is no `success_url` / `cancel_url` on this create body today. The hosted page owns
the post-pay experience; reconcile via [webhooks](/developers/16-webhooks.md).
> The field is **`link_type`**, not `type`. Values are **`FIXED` | `DONATION` |
> `SUBSCRIPTION`**. Not `CUSTOM` / `PRODUCT`. Unknown values fall back to `FIXED`.
### Example: a fixed one-off amount
```bash
curl https://api.worthypayments.com/v1/checkout-links \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Order #1234",
"amount": 4999,
"currency": "usd",
"link_type": "FIXED"
}'
```
### Response
```json
{
"id": "9f2c1b84-77ad-4e0c-9c31-0b6e5d2a4413",
"object": "checkout_link",
"url": "https://app.worthypayments.com/checkout/abc123",
"code": "abc123",
"active": true
}
```
Redirect the shopper to `url`. It is absolute and points at our hosted checkout, so it is
safe to hand straight to a redirect. `code` is the short slug at the end of it, use that
if you need to build the link yourself.
---
## Link types
| `link_type` | Use it for |
|-------------|------------|
| `FIXED` | A one-off charge for a specific `amount` you set at creation. **Default.** |
| `DONATION` | A donation-style page where the shopper can enter their own amount. |
| `SUBSCRIPTION` | A recurring / subscription-oriented link. |
For a catalogue product’s own share URL, see [Products](/developers/05-products.md). Product
binding is not a field on this create body.
---
## Read your links back
```
GET https://api.worthypayments.com/v1/checkout-links
GET https://api.worthypayments.com/v1/checkout-links/{id}
```
Scope: **`checkout_links:read`**. A separate grant from `checkout_links:create`. Tick it
on the key in **Settings → Developers → API keys**; a key that only creates links gets
`403 permission_denied` here.
The list is cursor-paged the same way as [`GET /charges`](/developers/13-pagination.md), and
`next_cursor` is **omitted** rather than `null` when there is no next page.
| Query | Notes |
|-------|-------|
| `limit` | Default `10`, max `100`. |
| `starting_after` | The previous page's `next_cursor`. |
| `active` | Optional filter. Absent lists both active and inactive links. |
| `link_type` | Optional filter: `FIXED` \| `DONATION` \| `SUBSCRIPTION`. |
```json
{
"object": "list",
"data": [
{
"object": "checkout_link",
"id": "df62a7a3-e2de-4f47-8635-e3c9adfebddf",
"url": "https://app.worthypayments.com/checkout/lKHx64iW",
"code": "lKHx64iW",
"name": "Order #1234",
"description": "",
"link_type": "FIXED",
"currency": "USD",
"amount": 4999,
"min_amount": null,
"max_amount": null,
"max_uses": null,
"times_used": 0,
"active": true,
"product_id": null,
"store_id": null,
"expires_at": null,
"created": 1788100472,
"updated": 1788100472
}
],
"has_more": false
}
```
`link_type` reads back as the same value you sent, `FIXED`, not the internal
`PAYMENT_LINK_TYPE_FIXED`. Timestamps are Unix seconds. `amount` is minor units, and is
`null` on a `DONATION` link with no fixed amount.
---
## Changing the price
**A link's `amount` is fixed once created. There is no update endpoint**, `PATCH` and
`PUT` on `/checkout-links/{id}` are not served and answer `404`. Two ways forward:
- **Create a new link** and redirect to that one instead. Old links keep working, so
deactivate the previous one in the dashboard if it must stop taking money.
- **Edit it in the dashboard** (**Payment Links → the link → Edit**), which can change
the amount in place and keeps the same `code`, so a URL you have already sent out stays
valid at the new price.
> **Re-pricing a cart mid-checkout is a different problem, and it has a real answer.** If
> the shopper is on your page rather than ours, use the
> [Payment Element](/developers/08b-payment-element.md) and
> [`PATCH /v1/payment_intents/{id}`](/developers/08b-payment-element.md): it rewrites the amount on
> an intent that is already mounted, the `client_secret` does not change, and nothing has
> to be re-rendered. Checkout links have no equivalent because the shopper is on our page
> by then, and the page has already been handed a price.
---
## After payment
The hosted page runs the entire payment lifecycle, including any 3-D Secure step-up. You
do not handle `requires_action` yourself in the redirect flow. Reconcile the order on
your side via webhooks or by reading the charge once you have its id. See
[Webhooks](/developers/16-webhooks.md).
> Prefer to keep the buyer on your domain instead of redirecting? Use the
> [Payment Element](/developers/08b-payment-element.md). For a pure server-to-server charge after
> your own tokenization, see [`POST /charges`](/developers/03-charges.md).
========================================================================
# Products
Products are reusable catalogue items, each with a fixed price and its own hosted
checkout URL. List them to power the **"fixed product → share a URL"** flow: create the
product once in the dashboard, then hand its `url` to a shopper to take a payment. No
per-charge API call required. Storefronts that hold those products are
[Storefronts](/developers/05b-stores.md).
```
GET https://api.worthypayments.com/v1/products
```
Scope: `products:read`.
---
## List products
`GET /products`
### Query parameters
| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `limit` | integer | `10` | Page size. Capped at `100`. |
| `starting_after` | string | — | The opaque `next_cursor` from the previous page. Not a product id. |
Paging is cursor-based, so there is no `page` parameter and no `active` filter.
Unrecognized query parameters are silently ignored, so filtering client-side on the
`active` field is the only option.
### Example
```bash
curl "https://api.worthypayments.com/v1/products?limit=10" \
-H "x-api-key: sk_live_xxx"
```
### Response
```json
{
"object": "list",
"data": [
{
"id": "4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1",
"object": "product",
"name": "Pro Plan",
"active": true,
"url": "https://app.worthypayments.com/checkout/proplan"
}
],
"has_more": false,
"next_cursor": "djJ8MjAyNi0wOC0yNFQxNDo0Njo1Nlp8NGIxZTc3YTA"
}
```
See [Pagination](/developers/13-pagination.md) for the cursor list model.
| Field | Type | Notes |
|-------|------|-------|
| `id` | string | Product id. |
| `object` | string | Always `"product"`. |
| `name` | string | Display name. |
| `active` | boolean | Whether the product is purchasable. |
| `url` | string \| null | The product's hosted checkout page. Share this to take a payment. `null` when the product has no active checkout link. |
> **Price is not returned.** The list carries no `amount` or `currency`, so you cannot
> render a price from this endpoint. Read it from the dashboard or from the hosted
> checkout page itself.
---
## The fixed-product flow
1. Create a product in the dashboard (name, price, currency).
2. `GET /products` to retrieve its `url`.
3. Put the URL anywhere a shopper can click it: an email, an invoice, a button, a QR code.
4. The hosted page runs fraud, routing, and 3-D Secure, then collects payment. Nothing
else to build.
This is the same hosted engine as a [checkout link](/developers/04-checkout-links.md), with the
product carrying a fixed price and a stable URL. To bind a custom checkout link to a
product, pass `product_id` to `POST /checkout-links`.
---
## Tax rates
Your own store (WooCommerce, a custom cart) can use the tax rates you keep with us: create
the rates, attach them to products, and ask what each cart line owes at checkout.
```
POST https://api.worthypayments.com/v1/tax_rates
GET https://api.worthypayments.com/v1/tax_rates
GET https://api.worthypayments.com/v1/tax_rates/{id}
POST https://api.worthypayments.com/v1/tax_rates/{id}
DELETE https://api.worthypayments.com/v1/tax_rates/{id}
POST https://api.worthypayments.com/v1/products/{id}/tax_rates
DELETE https://api.worthypayments.com/v1/products/{id}/tax_rates/{tax_rate}
POST https://api.worthypayments.com/v1/tax/calculate
```
Scopes: `tax:read` (list, retrieve, calculate) and `tax:write` (everything else).
`tax:write` is server-only. Neither is on a new key by default: tick them in
**Dashboard → Developers → API keys**.
### 1. Create a rate
`percentage` is a **percent**: `7.25` means 7.25 %. It comes back as the string `"7.25"`.
Creating requires an `Idempotency-Key`, so a retry never makes a second rate.
```bash
curl https://api.worthypayments.com/v1/tax_rates \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 5d0c7a52-3f1e-4c8b-9d2a-6e4f1b7c8a90" \
-H "Content-Type: application/json" \
-d '{"display_name":"CA sales tax","percentage":7.25,"tax_type":"sales_tax","country":"US","state":"CA"}'
```
Updating (`POST /tax_rates/{id}`) changes only the fields you send. Deleting stops the rate
applying at once; the record is kept so past tax stays explained. To pause a rate, send
`active: false` instead. The default rate and global rates are set in the dashboard.
### 2. Attach it to a product
```bash
curl https://api.worthypayments.com/v1/products/4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1/tax_rates \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"tax_rate":"6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11"}'
```
A product can carry several rates, and they stack. The product id is not checked against
the catalog: use the same id you will send as `line_items[].product` when you calculate.
### 3. Calculate at checkout
```bash
curl https://api.worthypayments.com/v1/tax/calculate \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"currency":"usd","address":{"country":"US","state":"CA"},
"line_items":[{"product":"4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1","amount":2000,"quantity":2,"reference":"cart-item-1"}]}'
```
`amount` is the line total in cents. The response gives each line its `amount_tax` and a
`tax_breakdown` per rate, plus `amount_subtotal`, `tax_amount_exclusive`,
`tax_amount_inclusive` and `amount_total`. The call writes nothing, so run it every time the
cart changes.
Each line picks its rates on its own, in this order: a global rate, then the rates
attached to its product, then your rates for the ship-to address, then your default rate.
A line with none of these pays no tax. Tax on an `inclusive` rate is already inside
`amount`, so only exclusive tax is added to `amount_total`. Shipping is not taxed by this
call. At most 100 lines per call.
========================================================================
# Storefronts
A storefront is a named shop under your account, slug, catalogue, and whether prices are
shown. List and retrieve them; create and edit stay in the dashboard. Amounts on the stats
roll-up are integer minor units (cents).
```
GET https://api.worthypayments.com/v1/stores
GET https://api.worthypayments.com/v1/stores/stats
GET https://api.worthypayments.com/v1/stores/{id}
```
| Scope | Grants |
|-------|--------|
| `stores:read` | List, stats, and fetch storefronts |
`GET /v1/stores/stats` is declared **before** `GET /v1/stores/{id}` so `stats` is
never parsed as an id.
Related: [Products](/developers/05-products.md) (catalogue items that live on a storefront),
[Checkout Links](/developers/04-checkout-links.md).
---
## List
`GET /v1/stores`
| Query | Notes |
|-------|-------|
| `limit` | Page size. Defaults to 10, capped at 100. |
| `starting_after` | Opaque cursor from the previous page. |
| `active` | Optional. Omit it to list both active and inactive. |
```bash
curl "https://api.worthypayments.com/v1/stores?limit=10" \
-H "x-api-key: sk_test_xxx"
```
```json
{
"object": "list",
"data": [
{
"id": "store_9x2",
"object": "store",
"slug": "acme-shop",
"name": "Acme Shop",
"description": "Everything Acme",
"active": true,
"show_prices": true,
"product_count": 12
}
],
"has_more": false
}
```
---
## Stats
`GET /v1/stores/stats`. One roll-up across every storefront: counts and `total_revenue` in
cents.
```bash
curl https://api.worthypayments.com/v1/stores/stats \
-H "x-api-key: sk_test_xxx"
```
```json
{
"object": "store_stats",
"total_stores": 3,
"active_stores": 2,
"inactive_stores": 1,
"total_products": 47,
"total_orders": 0,
"total_revenue": 0
}
```
---
## Retrieve
`GET /v1/stores/{id}`. The same object as a list row. Unknown id is `404
resource_missing`.
---
## What is not here
There is no public `POST` / `PATCH` / `DELETE` for storefronts. Create and edit them in
the dashboard. `store.*` webhooks are not delivered on this API. They record
administrative catalogue edits, not shopper events. See [Webhooks](/developers/16-webhooks.md).
========================================================================
# Refunds
Return funds for a previous charge, in full or in part.
```
POST https://api.worthypayments.com/v1/refunds
```
Scope: `refunds:create`, **server-only**. A publishable key (`pk_`) cannot refund; use a
secret key (`sk_`). See [Authentication](/developers/02-authentication.md).
---
## Create a refund
`POST /refunds`
### Request body
| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `charge` | string | Yes | The id of the charge to refund (the `id` from `POST /charges`, or from a charge synced from your gateway). |
| `amount` | integer | No | **Cents.** Omit for a **full** refund of what remains. Provide a smaller amount for a partial refund. Must be a whole number ≥ `1`; a negative, zero or fractional value is `400 parameter_invalid`. |
| `currency` | string | No | Read only with `amount`. When omitted it defaults to **`usd`**, so a partial refund of a non-USD charge must pass the charge's own currency. A different currency is refused (`REFUND_CURRENCY_MISMATCH`). |
| `reason` | string | No | One of `requested_by_customer`, `duplicate` or `fraudulent`. Any other value is `400 parameter_invalid`. |
The `Idempotency-Key` **header** is required. A missing header is `400 idempotency_key_required`,
and a body field does not take its place.
### Example: full refund
```bash
curl https://api.worthypayments.com/v1/refunds \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "reason": "requested_by_customer" }'
```
### Example: partial refund ($20.00 of a larger charge)
```bash
curl https://api.worthypayments.com/v1/refunds \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2000, "currency": "usd" }'
```
### Response: succeeded
```json
{
"id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33",
"object": "refund",
"charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"amount": 2000,
"currency": "usd",
"status": "succeeded",
"reason": "requested_by_customer",
"transaction_id": "01J..."
}
```
`amount` is always present: on a full refund (where you omit `amount` in the request) it is
the remaining captured cents the service refunded, not an omitted field. There is no
`created` field on this object.
### Response: failed
A refund that cannot be processed comes back as the standard error envelope. There is no
single `refund_failed` code, branch on what actually arrives:
**Unknown charge → `404`**
```json
{
"error": {
"type": "invalid_request_error",
"code": "resource_missing",
"message": "TRANSACTION_NOT_FOUND",
"request_id": "2358c7d9-…"
}
}
```
**Refused before any money moves → `400`**, with the reason in `message`:
| `message` | `code` | Means |
|---|---|---|
| `REFUND_EXCEEDS_REMAINING` | `invalid_argument` | `amount` is more than what remains. Refunds made through the API and in your gateway's own portal both count. |
| `ALREADY_FULLY_REFUNDED` | `failed_precondition` | Nothing is left to refund. |
| `TRANSACTION_DISPUTED` | `failed_precondition` | A chargeback or a matched pre-dispute alert is open on the charge. The cardholder may already be getting the money back, and a refund on top would pay twice. |
| `REFUND_WINDOW_EXPIRED` | `failed_precondition` | The charge is more than 180 days old (for a gateway-synced charge, from the gateway's own date). |
| `INVALID_STATE_FOR_REFUND` | `failed_precondition` | The charge never captured money. |
| `REFUND_CURRENCY_MISMATCH` | `invalid_argument` | `currency` is not the charge's. |
**Refused by the gateway → `400`** with `code: "refund_declined"`, the gateway's
`decline_code`, and its reason in `message`. No money moved. The refusal is the gateway's
answer: a retry with the same `Idempotency-Key` replays it; send a new key once you have
changed what made the gateway refuse.
```json
{
"error": {
"type": "invalid_request_error",
"code": "refund_declined",
"decline_code": "generic_decline",
"message": "Refund amount may not exceed the transaction balance",
"request_id": "2358c7d9-…"
}
}
```
A processor failure is **`500`** with `code: "upstream_error"`. A `500` is the ambiguous case:
the refund may or may not have been submitted. Reconcile with
`GET /v1/charges/{id}` (its `amount_refunded`), or retry with the **same** `Idempotency-Key`,
rather than retrying blindly. Edge validation (missing `charge`, a non-integer or non-positive
`amount`, an unknown `reason`) is the separate `400` `parameter_invalid`.
See [Errors](/developers/10-errors.md) for the full envelope and retry guidance.
---
## Retrieve a refund
`GET /refunds/:id` requires `payments:read`. It returns the fuller stored shape, including
`currency` and `reason`:
```json
{
"id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33",
"object": "refund",
"charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"amount": 2000,
"currency": "usd",
"status": "succeeded",
"reason": "duplicate",
"transaction_id": "01J..."
}
```
`status` maps to `succeeded` / `pending` / `failed`. Unknown id → `404 resource_missing`.
## List refunds
`GET /refunds` requires `payments:read`. Cursor-paginated, newest first. Accepts
`limit` (default 10, max 100), `starting_after`, and `charge` to filter to one charge.
See [Pagination](/developers/13-pagination.md).
The `charge` filter lists the refunds made through the API. A refund made in your gateway's own
portal points at the gateway's id and is not listed under the charge's `id`. For the full total
a charge has given back, read its `amount_refunded` (`GET /v1/charges/{id}`).
```bash
curl "https://api.worthypayments.com/v1/refunds?charge=TXN-8ac4796a-1940-4c6a-ba54-f151606330e3" -H "x-api-key: sk_live_xxx"
```
```json
{ "object": "list", "data": [ { "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33", "object": "refund", "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2000, "currency": "usd", "status": "succeeded", "reason": "duplicate", "transaction_id": "01J..." } ], "has_more": false }
```
---
## Notes
- Refunds settle through the same acquirer (`payarc`, `nuvei`, `worthy`) that
processed the original charge. You don't specify it.
- A refund goes back to the card the charge was paid with, and only there. Each request
refunds one charge; there is no bulk refund.
- Partial refunds can be issued multiple times against one charge until the original
amount is exhausted.
- To refund a customer's past charges, for example instead of sending a payout, see
[Refund a customer's past charges](/developers/06b-refund-past-charges.md).
- Refunds are server-only by design: keep your `sk_` key on the backend and never trigger
refunds from the browser.
### Refunding the same charge twice
The protection against a double refund is your `Idempotency-Key`, not the charge id. Two
refunds against the same charge are perfectly legal (that is what partial refunds are) so
the API cannot tell an intentional second partial refund apart from an accidental replay.
**It only knows they are the same request if you send the same key.**
The practical rule: derive the key from the thing you are refunding, not from the moment
you are refunding it. A key like `refund:order-1042:full` collapses a retry into one
refund; a fresh `uuidgen` on every attempt does not, and a timed-out request retried
with a new key refunds twice.
Once the original amount is exhausted, further refunds are rejected upstream. You get a
`400` with `code: "invalid_argument"` and message `REFUND_EXCEEDS_REMAINING`.
### Rate limits
Refunds are not counted against a per-minute refund budget; only the general per-key
request ceiling applies (see [Rate limits](/developers/14-rate-limits.md)). A bulk refund script that
does hit a `429 rate_limit_exceeded` should back off by the `Retry-After` header.
========================================================================
# Refund a customer's past charges
Send money back to a customer by refunding charges they already paid, instead of sending a
separate payout. For example, a customer who has spent $7,000 with you and is owed $5,000 can be
refunded $5,000 across those earlier card charges. The money goes back to the cards they paid
with, and you skip a payout rail and its fees.
You need two calls, both available today:
```
GET https://api.worthypayments.com/v1/customers/:id/transactions (payments:read)
POST https://api.worthypayments.com/v1/refunds (refunds:create, server-only)
```
Before you build this, agree it with your acquirer and your finance team. Refunds count toward
your refund rate, and using refunds in place of payouts changes how the money is booked. This
guide covers the API side only.
---
## 1. List the customer's charges
```bash
curl "https://api.worthypayments.com/v1/customers/cus_8f1c…/transactions?limit=100" \
-H "x-api-key: sk_live_xxx"
```
`GET /v1/customers/{id}/transactions` returns the customer's **charges only**:
- Every sale and authorization is listed, including those synced from your gateway.
- Refund, void and capture rows never appear here.
- The list is newest first, at most 100 a page.
- To get the next page, pass the previous page's `next_cursor` as `starting_after`, and keep going
while `has_more` is `true`.
Each row carries what you need to know what it can still give back:
| Field | Meaning |
|---|---|
| `amount` | What the charge was, in cents. |
| `amount_refunded` | What it has already given back, in cents. API refunds and refunds made in your gateway's own portal are both counted. |
| `refunded` | `true` once that is the whole amount. |
| `disputed` | `true` while a chargeback or a matched pre-dispute alert is open on it. |
| `status` | `succeeded` for money taken. |
| `currency` | The charge's currency. Refund in the same one. |
| `created` | When the charge was made. |
| `card` | The card the charge was paid with: `brand`, `bin` (first six to eight digits, left out when we hold none) and `last4`. A refund of this charge goes back to this card. |
## 2. Keep to the person you owe
**Each refund goes back to the card its charge was paid with, and nowhere else.** One
`POST /v1/refunds` refunds exactly one charge. There is no bulk refund and no refund by customer,
so nothing here can send money to every card on an account at once. Which cards receive money is
decided entirely by which charges you refund.
That makes the choice of charges yours to get right. A customer record gathers every charge made
under it, and one account can hold charges paid with several cards: a new card after an old one
expired, a partner's card, or a card that was not the account holder's at all. Refunding "the
customer's charges" without looking at the card can pay someone other than the person you owe.
So before you refund:
1. **Decide which card(s) belong to the person you are paying**, from your own records: the card
they verified with you, the card on their payout request, or the card they confirm. Identify
each by `brand`, `bin` and `last4`.
2. **Refund only charges paid with those cards.** Skip every other charge, however refundable it
looks. A charge whose `card.bin` is missing cannot be matched on all three, so leave it out
unless your own records settle it.
3. **Send anything left over through your usual payout** to the account you have verified.
## 3. Pick what you can refund
A charge is refundable when **all** of these hold:
- it was paid with one of the cards you kept in step 2;
- `status` is `succeeded`;
- `refunded` is `false`;
- `disputed` is `false`;
- it is less than **180 days** old (`created`);
- what remains, `amount − amount_refunded`, is above zero.
Walk the list and take from each refundable charge what remains, until you reach the amount you
owe. Any order works. Newest first is a sound default, because those cards are the most likely
to still be open. If the refundable total is less than you owe, send the rest through your usual
payout.
## 4. Refund each charge
One `POST /v1/refunds` per charge, with an explicit `amount`:
```bash
curl https://api.worthypayments.com/v1/refunds \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: payout-1042:TXN-8ac4796a-1940-4c6a-ba54-f151606330e3" \
-H "Content-Type: application/json" \
-d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2500, "currency": "usd", "reason": "requested_by_customer" }'
```
- **`Idempotency-Key`** (header, required). Derive it from the payout and the charge, for example
`payout-1042:`. A retried request then refunds once. A fresh key on every attempt
can refund twice. See [Idempotency](/developers/12-idempotency.md).
- **`amount` and `currency`**. Always pass both. Without `currency` the request is read as `usd`,
so a partial refund of a non-USD charge would be refused.
- **`reason`**. One of `requested_by_customer`, `duplicate` or `fraudulent`, or leave it out.
```js
// Node 18+. `owed` in cents; `charges` are the rows from step 1 (`created` is Unix seconds).
// `ownCards` are the payee's verified cards from step 2, e.g.
// [{ brand: "visa", bin: "424242", last4: "4242" }].
const DAY_MS = 86_400_000;
function paidWithOwnCard(c, ownCards) {
const card = c.card ?? {};
return ownCards.some(
(own) =>
card.brand === own.brand &&
card.last4 === own.last4 &&
card.bin != null &&
card.bin.startsWith(own.bin),
);
}
function refundable(c, ownCards) {
const ageDays = (Date.now() - c.created * 1000) / DAY_MS;
return (
paidWithOwnCard(c, ownCards) &&
c.status === "succeeded" &&
!c.refunded &&
!c.disputed &&
ageDays < 180
);
}
async function refundOne(c, amount, payoutId, apiKey) {
for (;;) {
const res = await fetch("https://api.worthypayments.com/v1/refunds", {
method: "POST",
headers: {
"x-api-key": apiKey,
// Same key on every retry of this charge in this payout: it refunds once.
"Idempotency-Key": `payout-${payoutId}:${c.id}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ charge: c.id, amount, currency: c.currency }),
});
if (res.status !== 429) return res.ok;
// The per-key request ceiling was hit: wait as told, then retry the same request.
const wait = Number(res.headers.get("Retry-After") ?? 60) * 1000;
await new Promise((r) => setTimeout(r, wait));
}
}
async function refundOwed(charges, ownCards, owed, payoutId, apiKey) {
let left = owed;
for (const c of charges) {
if (left <= 0) break;
const remaining = c.amount - c.amount_refunded;
if (!refundable(c, ownCards) || remaining <= 0) continue;
const amount = Math.min(remaining, left);
// A refused refund (see below) moves no money: skip that charge, try the next.
if (await refundOne(c, amount, payoutId, apiKey)) left -= amount;
}
return left; // what still has to go through your usual payout
}
```
## What the API refuses for you
The server checks every refund itself, so a list that is a moment out of date cannot make you
pay twice. A refused refund moves no money and answers `400`, with the reason in the message:
| Message | Means | Do |
|---|---|---|
| `TRANSACTION_DISPUTED` | A chargeback or matched alert is open on the charge. The cardholder may already be getting this money back. | Skip it. |
| `REFUND_EXCEEDS_REMAINING` | `amount` is more than what remains, which a refund made meanwhile can cause. | Read the charge again and refund what remains. |
| `ALREADY_FULLY_REFUNDED` | Nothing is left. | Skip it. |
| `REFUND_WINDOW_EXPIRED` | The charge is over 180 days old. | Skip it. |
| `INVALID_STATE_FOR_REFUND` | The charge never captured money. | Skip it. |
| `REFUND_CURRENCY_MISMATCH` | `currency` is not the charge's. | Send the charge's `currency`. |
A `500` is the one ambiguous answer: the refund may or may not have gone through. Retry with the
**same** `Idempotency-Key`, or check `GET /v1/charges/{id}`, before you count it either way.
## Pace, then confirm
- **Rate limit.** There is no per-minute refund budget, only the general per-key request
ceiling. Back off on a `429` by `Retry-After`. See [Rate limits](/developers/14-rate-limits.md).
- **Confirmation.** Each refund raises a `charge.refunded` webhook ([Webhooks](/developers/16-webhooks.md)).
`GET /v1/charges/{id}` shows the new `amount_refunded`. Book the payout as settled by refunds
for the sum that succeeded.
See also [Refunds](/developers/06-refunds.md) and [Customers](/developers/18-customers.md).
========================================================================
# 3-D Secure
3-D Secure (3DS) is an issuer-driven step-up that asks the cardholder to prove they are
who they say they are, usually with a one-time code or an in-app approval. A successful
authentication shifts liability for fraud-related chargebacks to the issuer; other disputes
(not received, not as described) stay with you.
> **Readiness.** Call `GET /v1/3ds/config` with a **secret** key (`sk_`, scope
> `threeds:read`). `enabled` is a per-environment platform flag: it is `true` when the
> platform's 3DS provider is configured for that environment (test or live). It is not a
> per-merchant or per-acquirer check. Test mode is configured, so 3-D Secure runs there.
> When `enabled` is `false`, `POST /v1/3ds/sessions` and `POST /v1/3ds/retry-decline`
> answer **409** `three_ds_not_enabled` and do **not** open a provider session. A
> publishable key (`pk_`) on `/v1/3ds/config` is **403** `permission_denied`
> (`threeds:read` is not a browser scope). That is not "3DS is off". The Payment Element
> reads team/app ids from `GET /v1/evervault-config` (scope `none`, `pk_` allowed) and
> never calls `/v1/3ds/config`. Use the secret key for `enabled`; use `/evervault-config`
> from the page.
The rest of this page is the contract when `enabled` is `true`. When it is `false`, treat
`/v1/3ds/sessions` and `/v1/3ds/retry-decline` as unavailable.
```
GET /v1/3ds/config # requires threeds:read + the "charge" exec-flow
# → { object, team_id, app_id, provider, enabled }
# enabled: false → sessions and retry-decline are 409
POST /v1/3ds/sessions # 409 three_ds_not_enabled when config.enabled is false
GET /v1/3ds/sessions/:id # server-managed, authoritative (cavv/eci), needs sk_
GET /v1/3ds/sessions/:id/status # browser-safe, liveness only, works with pk_
POST /v1/3ds/retry-decline # unknown original_transaction_id → 404 resource_missing
# enabled: false (known txn) → 409 three_ds_not_enabled
```
> You normally never call session create or retrieve directly. When a charge is stepped
> up, the charge engine opens the session and returns its `session_id` inside
> `next_action`. Do not poll a session that this surface refused to open.
`GET /v1/3ds/config` in an environment where 3DS is configured looks like this. `enabled`
is the field to branch on, not `team_id`/`app_id` being present:
```json
{
"object": "three_ds_config",
"team_id": "team_...",
"app_id": "app_...",
"provider": "evervault",
"enabled": true,
"apple_pay_enabled": false,
"google_pay_enabled": false,
"evervault_merchant_id": ""
}
```
`POST /v1/3ds/retry-decline` with an unknown `original_transaction_id` (for example
`TXN-deadbeef`) is **404** `resource_missing` even when 3DS is disabled. That lookup
runs before readiness, so a fake original never becomes a provider error.
---
## The contract
1. Create a [charge](/developers/03-charges.md). The server runs fraud + routing, then decides on a
step-up.
2. If a step-up is needed, the charge returns:
```json
{
"object": "charge",
"status": "requires_action",
"next_action": {
"type": "three_ds_challenge",
"three_ds": {
"session_id": "tds_visa_439ab3fc28d1"
}
}
}
```
3. Run the challenge (see [Running the challenge](#running-the-challenge)). When it
completes, **resubmit the same charge using the same `Idempotency-Key`**, with
`three_ds.session_id` set to the value above.
4. The resubmitted charge returns `succeeded` (or an [error](/developers/10-errors.md)). It charges
only if the session authenticated — see
[A session that did not authenticate](#a-session-that-did-not-authenticate).
Authentication can precede charge creation: `requires_action` carries **no charge `id`
until authentication completes**. Any real transaction id is preserved, and payment
intents retain their intent id. `requires_action` is not a terminal idempotency response.
A saved card that needs 3DS is authenticated on the encrypted copy kept when it was saved.
One saved before 2026-10-18 or imported, with no such copy, is declined with
`soft_decline_3ds` (402 `card_error` / `card_declined` on charges). Ask the customer to
pay again with their card. On intents the decline returns 200 `requires_payment_method`
with `last_payment_error`, and the Payment Element asks the buyer to re-enter the card
([3DS on a saved card](#3ds-on-a-saved-card)). Authenticated payments, ACH, wallets and
merchant-initiated charges retain their exemptions.
Temporary initiation or session-storage failures return 503 `api_error`; retry with the
same key. Deterministic provider rejections use the normal `processing_error` decline.
A response without a usable session is a 502 `api_error`, never `requires_action`.
The client sends back **only the `session_id`**. It never builds or posts a cryptogram.
WorthyPayments retrieves the authoritative cryptogram (CAVV/ECI) server-side from the session
before authorizing. A client-supplied cryptogram is never trusted.
> **Hard rule:** do not put `cavv`, `eci`, `cryptogram`, or `auth_value` on the charge body.
> They are not accepted as proof of authentication. Wallet charges must not carry
> `three_ds` at all (the API rejects that combination). See
> [Integration hard rules](/developers/27-integration-hard-rules.md#3-3-d-secure-session-id-only).
---
## When does a step-up happen?
The charge engine decides, unless you ask (see [Asking for 3DS](#asking-for-3ds)). It is
worth knowing what it decides on — mostly so you can tell "no challenge appeared" apart from
"something is broken".
A charge is stepped up when **any** of these is true:
| Trigger | Where it comes from |
|---|---|
| You asked | `three_ds.request` is `any`, `challenge` or `challenge_mandated` on the charge, or on the payment intent it confirms |
| The risk score is in the 3DS band | The default 3DS policy steps up a charge whose fraud risk score is 70 to 95. If you decide 3DS yourself with `three_ds.request`, turn the band off (or move it) with `POST /v1/fraud/three_ds_policy` — see [Fraud checks](/developers/23-fraud-checks.md#the-risk-score-3ds-step-up). |
| The customer is marked **Require 3DS** | The dashboard, in the mode you are viewing: **Require 3DS** on one of the customer's transactions, or **Require 3D Secure** on customers selected in the Customers list. Every later charge carrying that customer's email is stepped up (a different or missing email is a different customer; a merchant-initiated charge never is) until you click **Turn off** under **Restrictions** on the customer's page. Your server can read, set and clear the same flag with `GET` / `POST` / `DELETE /v1/customers/{id}/three_ds` — see [Fraud checks](/developers/23-fraud-checks.md#3ds-and-fraud-controls-from-your-own-tools). |
| Another fraud rule asked for it | Card testing, a velocity rule, a signal rule (proxy, a distrusted device), or a network signal |
| The winning route has **Attempt 3DS** on | The route's own toggle on the Payment Workflow canvas (read it with `GET /v1/payment_workflow/routes`) |
Nothing about the card itself is a step-up reason: a first-time card is not one, and no
card number (a test card included) triggers 3DS on its own.
Four kinds of charge are excluded, and the reasons differ:
- **A merchant-initiated charge** — a subscription renewal, a decline-recovery retry, an
invoice auto-charge, or your server charging a saved card on `POST /v1/charges`. There is
no cardholder to complete a challenge, and card networks exempt these charges, so none of
the triggers above applies to them: they are charged without 3DS. A blocking fraud rule
still blocks them ([3DS on a saved card](#3ds-on-a-saved-card)).
- **ACH** — there is no cardholder challenge to run.
- **Apple Pay, and Google Pay with a network token** — the wallet already carries its own
cryptogram and SCA. Stacking 3DS on top is refused by the processor, so a step-up would
turn a good payment into a declined one. The wallet's own liability shift is not
automatic: Google Pay shifts it only when its ECI is `05` (Visa) or `02` (Mastercard), and
leaves it with you on any other value and on other networks.
- **A charge already carrying an authentication** — the resubmit after a completed
challenge, whose session yielded a cryptogram. It goes straight through, which is what
stops an authentication loop. An ECI without a cryptogram is not an authentication and
exempts nothing.
A Google Pay `PAN_ONLY` credential is **not** in that list. It carries no cryptogram, so it
is charged as an ordinary card — and can be stepped up like one.
```
charge
│
├─ ACH? ─────────────────────── yes ─→ no step-up
├─ wallet with a cryptogram? ── yes ─→ no step-up (wallet SCA stands)
├─ already authenticated? ───── yes ─→ no step-up (this is the resubmit)
├─ merchant-initiated? ──────── yes ─→ no step-up (renewal, retry, server charge)
│
├─ fraud scoring asked for 3DS? ─┐ (risk band, Require 3DS customer, a rule)
├─ the route says Attempt 3DS? ─┼─ any ─→ STEP UP
├─ you sent three_ds.request? ─┘ │
│ │
└─ none ─→ charge proceeds │
▼
status: requires_action
next_action.three_ds.session_id
│
▼
you mount the challenge (once)
│
┌──────────────────────┴───────────────────┐
▼ ▼
issuer goes frictionless issuer presents a challenge
(nothing shown) (shopper enters a code)
└──────────────────┬───────────────────────┘
▼
resubmit with three_ds.session_id
▼
succeeded, or an error
```
### Whether the shopper actually sees a screen
When one of your rules triggers a step-up, WorthyPayments tells the issuer a challenge is
**mandated** (EMV 3DS challenge indicator 04) rather than inviting it to decide. That is
deliberate: a control named Require 3DS that produces no challenge is indistinguishable
from a broken integration, and an issuer offered the choice will usually take the
frictionless path. When the step-up is yours (`three_ds.request`), the issuer is told your
preference instead — see [Asking for 3DS](#asking-for-3ds).
The issuer still has the last word. It may authenticate frictionlessly anyway, in which
case your widget completes with nothing shown and the charge carries the same liability
shift it would have had after a challenge. Both outcomes are success; only the shopper's
experience differs.
## Asking for 3DS
Send `three_ds.request` on `POST /v1/charges`, or on `POST /v1/payment_intents` for a
payment the Payment Element confirms:
| `three_ds.request` | 3DS runs | The issuer is told |
|---|---|---|
| `automatic` (default) | when your rules ask | mandated (04) |
| `any` | always | no preference (01): frictionless whenever the issuer allows |
| `challenge` | always | challenge requested (03): your preference, the issuer may still go frictionless |
| `challenge_mandated` | always | mandated (04) |
- **`any` is the usual choice.** The issuer skips the challenge whenever its own risk check
allows, and a frictionless authentication shifts liability exactly as a challenge does, so
it protects the payment at the lowest cost in abandoned checkouts. Send `challenge` when
you have your own reason to doubt a payment, and `challenge_mandated` only when a rule
obliges strong authentication: every challenge shown is a step where some shoppers leave.
- **Your rules outrank a request.** When they ask for 3DS too, the challenge is mandated
whatever you sent: `any` cannot soften a challenge your risk rules demanded.
- **It needs a card entered for this payment** (an `ev:` card token). On
`POST /v1/charges` a request on a saved card, a gateway vault id, a `wp:` envelope or a
wallet is a 400 `parameter_invalid` (`param: three_ds.request`); to authenticate a saved
card there, collect it again (Payment Element or `tokenize`) and send it as
`card.number`. On a payment intent the Element does that for you: see
[3DS on a saved card](#3ds-on-a-saved-card). A wallet or a card sealed on the `wp:` rail
(the Element uses `ev:` unless you chose otherwise) pays without it.
- The step-up then works exactly as in [The contract](#the-contract). Repeating `request`
on the resubmit is fine.
A session you open yourself (`POST /v1/3ds/sessions`, or `/v1/3ds/retry-decline`) takes
the same values as a top-level `request`, and the issuer is told that preference. Left out,
the session carries no preference, as before.
## 3DS on a saved card
A saved card is a reference to a card held by the gateway. 3-D Secure needs the card
itself. When a card is saved through the Payment Element, the hosted checkout or a
card-update page, WorthyPayments keeps the card number in the encrypted form the buyer's
browser produced (only our encryption provider can decrypt it). A saved card with that
encrypted copy is **authenticated in place**:
1. A buyer pays with a saved card in the Payment Element (a payment intent), on a hosted
checkout page (a payment link, an invoice, a store order, a subscription's first
payment), or on your own checkout, where your server charges it on `POST /v1/charges`
with `"initiated_by": "customer"`. The payment needs 3DS: your `three_ds.request`, or any
of your rules in [When does a step-up happen?](#when-does-a-step-up-happen), including
the route's **Attempt 3DS**, the 3DS risk band and a customer marked **Require 3DS**.
2. The answer is `requires_action` with a `three_ds.session_id`, exactly as for a card
typed for this payment ([The contract](#the-contract)). The Element and the hosted page
run the challenge themselves. On your own checkout you run it in the buyer's browser
([Running the challenge](#running-the-challenge)) and resubmit the same charge with
`three_ds.session_id` under the same `Idempotency-Key`.
3. After the buyer authenticates, the saved card is charged with the authentication
attached. The buyer does not type the card.
**Cards saved before 2026-10-18, and cards imported from your gateway vault**, have no
encrypted copy. For them the card is collected once more:
1. That attempt is declined with `soft_decline_3ds` (on an intent: 200
`requires_payment_method`, `last_payment_error.decline_code: soft_decline_3ds`; on
`POST /v1/charges`: 402 `card_declined`, `decline_code: soft_decline_3ds`).
**Nothing is charged.** The intent or checkout stays payable.
2. The [Payment Element](/developers/08b-payment-element.md#3-d-secure-on-a-saved-card), or the
hosted page, hides the saved cards, opens the card form without the save box, and asks
the buyer to enter the same card again ("Your bank needs to verify this card…", in
English or Spanish). Your own checkout does the same: collect the card in the Payment
Element for this payment.
3. That card is a new attempt: it is authenticated like any card entered for the payment,
and charged. It is not saved as a second card. When it is the same card as the saved
one, its encrypted copy is kept on the saved card, so the next 3DS on that card is
authenticated in place: the buyer types it once, never again.
You receive a `charge.failed` for the declined saved-card attempt (`decline_code`
`soft_decline_3ds`) and then the normal events for the re-entered card. Only the second
attempt moves money. The declined attempt is not retried by decline recovery and does not
count toward card-testing or per-IP decline velocity.
**A merchant-initiated charge is never sent to 3-D Secure.** A subscription renewal, a
decline-recovery retry, an invoice auto-charge, or your server charging a saved card on
`POST /v1/charges` without `"initiated_by": "customer"` has no cardholder to complete a
challenge, and card networks exempt these charges from strong customer authentication. They
are charged without 3DS whatever asked for it: the route's **Attempt 3DS**, the 3DS risk
band, a customer marked **Require 3DS**, or a fraud rule (card testing, a velocity or signal
rule) that asks for 3DS. A fraud rule that **blocks** still blocks them. A
`three_ds.request` your server sends on such a charge is refused up front with a 400.
| Path | Rules ask for 3DS (route, risk band, Require-3DS customer, fraud rule) | `three_ds.request` |
|---|---|---|
| Payment Element / hosted checkout / `/v1/charges` with `initiated_by: customer`, saved card with an encrypted copy | authenticated in place | authenticated in place |
| The same, older or imported saved card | collected again, then authenticated | collected again, then authenticated |
| Card typed for this payment | authenticated | authenticated |
| Renewal, recovery retry, invoice auto-charge, `/v1/charges` on a saved card without `initiated_by: customer` | charged without 3DS | 400 on `/v1/charges` |
| Wallet (Apple Pay / Google Pay), ACH | charged without 3DS | charged without 3DS |
## A session that did not authenticate
A resubmit with `three_ds.session_id` charges only when that session authenticated with a
cryptogram — fully, or an "attempted" authentication that carries one. Otherwise nothing
reaches the card network:
| Session | Response |
|---|---|
| Failed | 402 `card_error` / `card_declined`, `param: three_ds.session_id`, `decline_code` `three_ds_failed`, `three_ds_card_not_enrolled` or `processing_error`. Stored under the key like any decline: a new attempt is a new session and a new key. |
| Still open | 409 `three_ds_session_incomplete`. Not stored: resubmit with the same key once the challenge finishes. |
| Authenticated, result not readable just now | 503 `three_ds_result_unavailable`. Not stored: retry with the same key. |
`GET /v1/3ds/sessions/:id` (and `/status`) names why a session failed in `failure_reason`:
| `failure_reason` | Meaning | `decline_code` on the charge |
|---|---|---|
| `authentication_failed` | The cardholder did not pass the challenge | `three_ds_failed` |
| `canceled` | The cardholder closed the challenge | `three_ds_failed` |
| `abandoned` | The challenge timed out | `three_ds_failed` |
| `rejected` | The issuer refused to authenticate | `three_ds_failed` |
| `challenge_required` | A challenge was needed and the session was opened not to show one | `three_ds_failed` |
| `card_not_enrolled` | The card could not be authenticated and no attempt was recorded for it (for example, the card is unknown to the network's directory or has no supported 3DS version) | `three_ds_card_not_enrolled` |
| `processing_error` | A 3DS server, directory or issuer-ACS fault | `processing_error` |
### When the issuer cannot authenticate
An issuer that does not take part in 3-D Secure normally leaves no gap: the card network
answers in its place, and the session ends **attempted** (ECI `06`, or `01` on Mastercard)
with a cryptogram. The charge goes through, the shopper sees nothing, and liability normally
shifts (see [Liability shift](#liability-shift)).
Only when nobody can answer, because the card is unknown to the network's directory or the
directory or issuer is down, does the session fail with `card_not_enrolled` or
`processing_error`. The charge is then refused, never quietly sent without 3DS. An unenrolled
card leaves you a choice the others do not: charge it again without `three_ds` if you accept
that no liability shifts, or ask for another card.
The challenge indicator plays no part in this. `01`, `03` and `04` speak to an issuer that
can authenticate: a mandate does not make a non-participating issuer answer, and `any` does
not turn into a charge without 3DS.
## Running the challenge
**Mount the widget once.** It handles both outcomes itself: a frictionless authentication
completes with nothing shown, and a challenge is presented only when the issuer asks for
one. Keep the widget hidden until it signals it is ready, so a frictionless pass shows the
shopper nothing.
> ### ⛔ Do not probe first with `failOnChallenge`
>
> An earlier version of this page recommended a two-pass flow: an invisible probe mounted
> with `failOnChallenge`, then a visible re-mount if the probe reported a challenge was
> needed. **That second pass can never work, and this guide was wrong to teach it.**
>
> `failOnChallenge` does not report — it *acts*. From the provider's own documentation:
>
> > If a challenge is requested, Evervault automatically **fails the 3DS session**, which
> > prevents the challenge from ever being shown.
>
> So the probe does not ask a question, it answers it destructively. By the time the second
> pass tries to re-render, the session is already `failed`, and every challenge-flow card
> ends as a declined payment with no screen ever shown. It looks like an issuer problem and
> is not.
>
> If you built the two-pass flow from this page, delete the probe. One mount is all the
> provider asks for.
Once authentication completes, resubmit the charge. The resubmit carries the session id and
nothing else about the authentication:
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: ord_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"card": { "number": "ev:...", "cvv": "ev:...", "exp_month": "12", "exp_year": "2027" },
"three_ds": { "session_id": "tds_visa_439ab3fc28d1" }
}'
```
Resubmit the **same** charge: same amount, same currency, same instrument. The 3DS session
is bound to the amount it authenticated. A resubmit with a different amount or currency is
refused before any charge with `400 invalid_request_error`, `code: three_ds_amount_mismatch`,
`param: three_ds.session_id`: run a new challenge for the amount you are charging.
`session_id` is the only field `next_action.three_ds` carries. The ACS URL, `creq` and
challenge URL are resolved inside the 3DS flow and are never surfaced on the charge
response.
> 3-D Secure runs on the `ev:` tokenization rail. A `wp:v1:` envelope in `card.wp_token`
> does not carry a step-up, so tokenize to `ev:` when a charge may need authentication.
> See [Card tokenization](/developers/28-card-tokenization.md).
---
## Bootstrapping the challenge widget
Only after `GET /v1/3ds/config` reports `enabled: true`. Client ids for the widget come
from the public config endpoint:
```
GET https://api.worthypayments.com/v1/evervault-config
→ { "object": "evervault_config", "mode": "live", "team_id": "team_...", "app_id": "app_..." }
```
Those values initialize the Evervault 3DS UI, mount the (hidden, then visible) challenge
iframe, and report the result. The endpoint is scope-free, so a publishable key can read
it, unlike `GET /3ds/config`, which requires `threeds:read` because opening a session bills
your acquirer and a browser key must never be able to do that.
The ids are resolved **per mode**: a test key gets the platform's test app, a live key its
live app. An `ev:` token is encrypted against one Evervault app and only that app can
decrypt it, so never hardcode them: a token minted against the wrong app fails at the
gateway with an opaque error rather than at encryption time.
---
## Liability shift
When 3-D Secure authentication succeeds, liability for a fraud-related chargeback shifts
from you (the merchant) to the card issuer. Declines and disputes drop on authenticated
traffic. Because WorthyPayments decides per-charge when a step-up is worthwhile, most
shoppers are never stepped up at all — see
[When does a step-up happen?](#when-does-a-step-up-happen).
A frictionless authentication carries the **same** liability shift as a completed
challenge. So a charge that came back `requires_action` and then finished with nothing
shown to the shopper is not a weaker outcome; it is the best one.
### What the ECI says
`GET /v1/3ds/sessions/:id` returns the `eci` (Electronic Commerce Indicator): the network's
two-digit record of how the issuer answered. The same digits mean different things on
different networks, so read them together with the card's brand:
| What happened | Visa, Amex, Discover, JCB | Mastercard | `cardholder_auth` | The charge |
|---|---|---|---|---|
| The issuer authenticated the cardholder, frictionless or after a challenge | `05` | `02` | `verified` | Goes through. Liability shifts. |
| The issuer could not authenticate and the network answered in its place | `06` | `01` | `attempted` | Goes through. Liability normally shifts. |
| No authentication | `07` | `00` | (empty) | Refused, see [A session that did not authenticate](#a-session-that-did-not-authenticate). |
Mastercard also returns `07` for an authenticated recurring payment, which shifts liability,
and `06` for an exemption, which does not.
`liability_shift` on the session is the final answer for that card. The networks exclude some
cases an ECI cannot show (on Visa, for example, an attempt on a non-reloadable prepaid card),
so read `liability_shift` rather than inferring the shift from `cardholder_auth` or the ECI.
The `04` in [Asking for 3DS](#asking-for-3ds) is not an ECI: it is the EMV 3DS challenge
indicator, a preference you send. The ECI is the result you get back.
---
## Which session endpoint to call
Which endpoint you poll depends on **which key you hold**:
| From | Endpoint | Key | Returns |
|---|---|---|---|
| Your server | `GET /v1/3ds/sessions/:id` | `sk_` (`threeds:read`) | The authoritative result: `cavv`, `eci`, `ds_trans_id`, `liability_shift`, `failure_reason` |
| The browser | `GET /v1/3ds/sessions/:id/status` | `pk_` | Liveness only: `status`, `authenticated`, `cryptogram_present`, `failure_reason` |
The split is deliberate. The authoritative response contains the cryptogram your charge
is authorised against, and a publishable key is public by construction. It ships in
your page source, so anyone reading the page could lift a completed authentication and
attach it to their own charge. A publishable key therefore **cannot** hold
`threeds:read`; calling the authoritative route with a `pk_` returns `403`.
You never need the cryptogram in the browser anyway: pass `three_ds.session_id` to
`POST /v1/charges` and the server fetches the authoritative value itself.
========================================================================
# Embedded SDK (`worthypayments.js`)
Reference for the browser SDK. The client publishes seven methods, and one of them is the
integration: `mountPaymentElement()` renders a payment form inside an iframe we serve, so
the card never enters your page. `mountExpressCheckout()` is its wallet-only counterpart:
Apple Pay and Google Pay buttons with no card form, for a product page or a cart.
`Object.keys()` returns fifteen. The extras are frame internals, not features. See [what
the client
publishes](/developers/27-integration-hard-rules.md#15-the-browser-integration-is-the-payment-element)
before you call one.
For a walkthrough of taking a first payment, read
[Payment Element](/developers/08b-payment-element.md). This page is the specification.
---
## Loading the bundle
```html
```
That URL is pinned to a version. There is also an unversioned
`https://app.worthypayments.com/sdk/worthypayments.umd.js`, which always serves the newest
build. Do not point a live checkout at it: every deploy we make becomes an untested change
to your payment page. It exists for our own demos.
The bundle defines exactly one global, `window.WorthyPayments`, and it is the factory
function itself.
An npm package (`@worthypayments/js`) exists in the repository but is **not published
yet**. Until it is, the script tag is the supported install path.
---
## `WorthyPayments(publishableKey, options?)`
```js
const wp = WorthyPayments('pk_live_xxx', {
apiBase: 'https://api.worthypayments.com/v1',
elementOrigin: 'https://element.worthypayments.com',
timeout: 15000,
debug: false,
});
```
Pass a publishable key. The factory throws before a client exists if you pass anything
else.
| Key prefix | Result |
|---|---|
| `pk_` | Accepted |
| `sk_` | Throws `secret_key_in_browser` |
| Anything else | Throws `invalid_key` |
| Missing or not a string | Throws `missing_key` |
### `WorthyPaymentsOptions`
| Option | Type | Default | Behaviour |
|---|---|---|---|
| `apiBase` | `string` | `https://api.worthypayments.com/v1` when the SDK was loaded from a `worthypayments.com/sdk/` URL (as in these guides); `https://api.paygeyt.com/v1` otherwise | Where the SDK sends API calls. Must be https and must be a WorthyPayments host (`paygeyt.com` or `worthypayments.com`, or a subdomain). `localhost` over http is exempt for development. Anything else throws at construction. |
| `elementOrigin` | `string` | `https://element.worthypayments.com` when the SDK was loaded from a `worthypayments.com/sdk/` URL (as in these guides); `https://element.paygeyt.com` otherwise | Where the hosted card frame is served from. For staging. Must be https and must **not** be your own origin. The card fields are sealed off by the same-origin policy, so a same-origin frame throws `same_origin_frame`. `http://localhost` and `http://127.0.0.1` are exempt for development; **`http://[::1]` is not** (unlike `apiBase`, which does accept it). |
| `timeout` | `number` | `15000` | Per-request timeout in milliseconds. A payment confirm waits at least 55 s, or this value if it is longer, because the charge behind it can outlast a read. |
| `debug` | `boolean` | `false` | Verbose console logging. |
`apiBase` is not a routing preference. It decides where the SDK fetches the public key
that card data is sealed to, so it is the destination of card data, and the host check is
a theft control rather than hygiene.
`elementOrigin` rejects your own origin with `same_origin_frame`. The PCI reduction rests
on the card fields living in a document your scripts cannot reach into, and same-origin
means they can.
The API base is `https://api.worthypayments.com/v1`.
### Local development
Point both options at your local stack. **They must be different origins**. The element is
a cross-origin frame by design, so serving your page and the frame from one port makes
`mountPaymentElement` throw `same_origin_frame`.
```js
const wp = WorthyPayments('pk_test_…', {
apiBase: 'http://localhost:8101/v1', // public API
elementOrigin: 'http://127.0.0.1:3005', // the frame — note the different host
});
```
Serving your own page from `http://localhost:5173` (or any port that is not the frame's)
satisfies the cross-origin rule. `localhost` and `127.0.0.1` count as different origins to
the browser, which is why the pair above works even though both are loopback.
Five things that trip people up locally:
- **Allowlist the dev host on the key.** `localhost` and `127.0.0.1` are ordinary entries
and must be listed when the key is minted, or the frame renders blank
(`frame-ancestors 'none'`). See [Quickstart step 2](/developers/01-quickstart.md#2-allowlist-your-domains).
- **Do not put `http://localhost` in a public URL query.** Local checkout with a test key
is allowed. The browser tells the API who it is with the `Origin` header. The SDK
already does this. A query string of `?origin=http://localhost…` on
`/v1/element-frameable` (or `?parent=http://localhost…` on the element host) is HTML 403
at the public edge and never reaches the API. That is not "local development is
forbidden". Prove the allowlist with the header, no query:
```bash
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
```
`200 {"object":"element_frameable","allowed":true}` when `localhost` is on the key. See
[Errors, loopback in the URL](/developers/10-errors.md#loopback-in-the-url).
- **`http://[::1]` is refused for `elementOrigin` only.** It is fine as your *page's*
origin: the allowlist check treats `localhost`, `127.0.0.1` and `[::1]` alike. The
restriction is on the option naming where the frame is *served from*, `apiBase` accepts
`[::1]`, `elementOrigin` does not. Use `127.0.0.1` there. Three different loopback rules
live in three different layers, and this is the only one that excludes `[::1]`.
- **A stale cached bundle looks exactly like a broken integration.** `/sdk/v1/` is served
with `max-age=14400`, so a page that loaded the SDK before a fix shipped keeps the old
one for up to four hours. The symptom is the worst kind: the iframe is there, the space it
reserved never fills with a form, `onError` does not fire and the console is clean. Hard-reload before you
debug anything else.
- **Your server still creates the intent.** The secret key does not become browser-safe
because the host is localhost (see [hard rule 6](/developers/27-integration-hard-rules.md).)
---
## The client
```ts
interface WorthyPaymentsClient {
mountPaymentElement(target: string | HTMLElement, opts: PaymentElementOptions): PaymentElementHandle;
mountExpressCheckout(target: string | HTMLElement, opts: ExpressCheckoutOptions): ExpressCheckoutHandle;
savedCards(customerSessionSecret: string): Promise;
ping(): Promise;
getFingerprint(): Promise;
getKountSessionId(): Promise;
getDeviceSession(): Promise;
}
```
That is the whole public browser surface.
---
## `mountPaymentElement(target, opts)`
Renders the payment form into `target` and returns a handle. Synchronous: it creates the
iframe and returns immediately, then the frame reports readiness through `onReady`.
`target` is a CSS selector or an element. If nothing matches, it throws
`container_not_found`.
The iframe is created with `allow="payment"`, `width: 100%`, and a starting height of 80px
that the frame then adjusts. Height is clamped to between **80px and 2000px**, because a
frame that could name its own height could paint over the host page. Apple Pay and Google
Pay do **not** run inside this iframe. They mount on your page above it. See
[Wallets](#wallets).
### `PaymentElementOptions`
| Option | Type | Required | Default | Behaviour |
|---|---|---|---|---|
| `clientSecret` | `string` | Yes, unless `mode` | — | From `POST /v1/payment_intents` on your server. Addresses the intent that holds the amount. See [Creating the intent](#creating-the-intent). |
| `mode` | `'payment'` | Instead of `clientSecret` | — | Mount before the intent exists; your server creates it at submit. Requires `amount`, `currency` and `createIntent`. See [Mounting before the intent exists](#mounting-before-the-intent-exists). |
| `amount` | `integer` | With `mode` | — | Smallest currency unit, positive (`1099` is $10.99). What the Apple Pay / Google Pay sheet shows. The amount charged is the one on the intent `createIntent` returns. |
| `currency` | `string` | With `mode` | — | ISO 4217, three letters (`'usd'`). |
| `createIntent` | `() => Promise` | With `mode` | — | Creates the payment intent on your server and resolves its `client_secret`. Runs once per payment attempt, after the form is complete. |
| `customerSessionSecret` | `string` | No | — | Shows that one buyer's saved cards inside the element. See [Customer sessions](#customer-sessions) for where this value comes from. |
| `appearance` | `Appearance` | No | — | Theme, variables and rules. See [Appearance](#appearance). |
| `locale` | `string` | No | `'en'` | UI language inside the frame. |
| `fields.billingDetails` | `'auto' \| 'never' \| { email?, name?, phone?, address? }` | No | `'auto'` | Which billing blocks the element draws. See below. |
| `defaultValues.billingDetails` | `{ email?, name?, phone?, address? }` | No | — | Prefill, and the values used for any block set to `'never'`. |
| `payButton` | `{ label?: string; display?: 'auto' \| 'never' }` | No | `{ display: 'auto' }` | `label` replaces "Pay" on the idle button (1-40 characters of plain text; anything else keeps "Pay"). `display: 'never'` draws no button: call `submit()` from your own. Mount-only. Its colour follows `appearance.variables.colorPrimary` (see [Appearance](#appearance)). |
| `onReady` | `() => void` | No | — | The frame has loaded and been initialised. |
| `onChange` | `(state: { complete: boolean; empty: boolean }) => void` | No | — | Field state changed. Use it to enable your own submit button. `complete` covers the card **and every billing field the element is drawing** — see below. |
| `wallets` | `'never'` | No | — | Suppresses Apple/Google Pay entirely. They mount on YOUR page above the card form, not in the frame, and their corner radius follows `appearance.variables.borderRadius`. |
| `walletButtons` | `{ applePay?: { theme?, type? }; googlePay?: { theme?, type? }; height?: number }` | No | black, provider's wording, 48 | How the Apple Pay and Google Pay buttons look. See [Wallet buttons](#wallet-buttons). Mount-only. |
| `recurring` | `WalletRecurring` | No | — | This payment starts a subscription. See [Subscriptions on the wallet buttons](#subscriptions-on-the-wallet-buttons). Mount-only. |
| `onSuccess` | `(result: FrameSuccess) => void` | **Yes** | — | The only required callback. |
| `onError` | `(err: WorthyPaymentsError) => void` | No | — | Declines and faults. Without it, errors are silent to your page. |
| `onLoadError` | `(err: WorthyPaymentsError) => void` | No | — | The form did not load at all, 10 s after mount (`code: 'element_load_failed'`). The usual cause is the key's allowed domains not covering this page; a CSP without `frame-src` is the next. Not a payment failure: offer a hosted checkout link, never "card declined". |
| `onCancel` | `() => void` | No | — | The buyer dismissed a wallet sheet. The card form has no cancel. |
| `onEscape` | `() => void` | No | — | The buyer pressed Escape inside the frame. Use it to close your own modal (see below) |
Note what is absent from a `clientSecret` mount: there is no `amount` and no `currency`.
The price lives on the intent your server created. A buyer editing your page cannot change
what they pay.
### Mounting before the intent exists
Pass `mode: 'payment'` with `amount`, `currency` and `createIntent` instead of
`clientSecret` when your page only creates the order at submit (Stripe's
`elements({ mode: 'payment', amount, currency })`):
```js
const element = wp.mountPaymentElement('#payment', {
mode: 'payment',
amount: 1099,
currency: 'usd',
createIntent: async () => {
const res = await fetch('/create-intent', { method: 'POST' });
const { client_secret } = await res.json();
return client_secret;
},
onSuccess: (result) => { /* show your confirmation */ },
onError: (err) => { /* err.code, err.message */ },
});
```
`createIntent` runs when the buyer presses Pay, when you call `element.submit()`, and when an
Apple Pay or Google Pay sheet is approved. It only runs once the form is complete, and once
per attempt: a retry after a decline calls it again. Your server may answer a retry with
the same unpaid intent's secret (an intent stays usable after a decline) or a new one.
Coming from Stripe: `createIntent` takes the place of `elements.submit()`, your fetch, and
`stripe.confirmPayment({ clientSecret })`. The element confirms by itself, with its own Pay
button and its own wallet buttons, so all three ways a payment starts go through this one
callback.
- **The price is still your server's.** `amount` is what the wallet sheet shows; the payment
charges the amount on the intent `createIntent` returns. A wallet payment whose intent has
a different amount or currency than the sheet is refused before any charge
(`amount_changed`). The card form shows no amount and checks none.
- **A failed `createIntent` charges nothing.** A rejection reaches `onError` as
`intent_creation_failed`, with your error's message; a value that is not a client secret as
`invalid_client_secret`. The element shows "The payment could not be started" and the
buyer can try again.
- **The amount never enters the frame.** It stays on your page for the wallet sheet.
- When the cart changes, call `element.update({ amount })` (see
[`PaymentElementHandle`](#paymentelementhandle)) so the wallet buttons redraw with the new
total, and create the intent with that total.
- Everything before the intent exists is keyed by your publishable key alone: card
encryption, device data, wallet availability and saved cards (`customerSessionSecret`)
all work at mount. Only confirm and 3-D Secure use the intent.
Mount errors, thrown as `invalid_request_error`: `client_secret_with_mode` (both given),
`invalid_mode` (anything but `'payment'`), `invalid_amount`, `invalid_currency`,
`missing_create_intent`. With neither `mode` nor a valid `clientSecret` the mount throws
`invalid_client_secret`, as before.
### Creating the intent
`clientSecret` comes from `POST /v1/payment_intents` on your server. That route requires
the server-only scope **`payment_intents:create`** (a publishable key is refused; minting
an intent is deciding the price). Send `Idempotency-Key` (or body `idempotency_key`);
omitting both returns `idempotency_key_required` before an intent exists.
| Field | Type | Required | Notes |
|---|---|---|---|
| `amount` | `integer` | Yes | Smallest currency unit. `4999` is $49.99. A decimal is rejected rather than rounded, because `49.99` would otherwise charge 49 cents. |
| `currency` | `string` | No | ISO 4217. Defaults to `usd`. |
| `description` | `string` | No | Shown on the charge. |
| `customer` | `string` | No | An existing customer of this key's merchant and mode: `cus_`, the bare uuid, or the `CUS-…` number. The payment and a saved card belong to this customer, whatever email the buyer types; without it, the buyer's email decides. Any other value is a 400 `resource_missing` with `param: customer`. |
| `receipt_email` | `string` | No\* | Where the receipt is sent. \*Optional on its own, but the pair is not: with neither this nor `billing.email` on confirm, the confirm fails `failed_precondition` / `customer email is required`. |
| `reference` | `string` | No | Your own order or cart id. |
| `idempotency_key` | `string` | One of header or body | Same value as `Idempotency-Key`. Keep it stable across retries of the same payment. |
Send the `client_secret` from the response to your page. The `sk_` stays on your server.
### Confirm (what the element calls)
The element confirms with `POST /v1/payment_intents/:client_secret/confirm`. That route is
**scope `none`**: the client secret is the capability, so a publishable key may call it
without a write scope. Integrators using the element never write this themselves.
The confirm step rejects both `amount` and `currency` with `parameter_unknown`. Rejecting
rather than dropping is deliberate: an integrator who believes they are updating the price
would otherwise ship a checkout that charges the original amount forever, with nothing to
notice.
### `locale`
BCP-47 tags. Today the frame ships English and Spanish: `es`, and anything
beginning `es-` (`es-MX`, `es-ES`), resolve to Spanish. Every other tag falls back to
English rather than failing.
### `fields.billingDetails` and `defaultValues`
`'auto'` draws email, name and street address inside the frame. `'never'` hides all
three. The object form hides only what you name:
```js
fields: { billingDetails: { address: 'never' } } // email + name still shown
```
Each block takes `'auto'` or `'never'`. `address` takes a third value, `'if_required'`.
**Phone is the exception: `'auto'` does not draw it.** Most payments do not need a phone
number, and an extra field costs conversion, so you opt in explicitly:
```js
fields: { billingDetails: { phone: 'auto' } }
```
#### `address: 'if_required'`
Draws only the address lines the buyer's country actually uses. Pick a country without
a state or province and that field is not drawn at all:
```js
fields: { billingDetails: { address: 'if_required' } }
```
⚠️ **Changing the country clears a state the new country does not have.** Select the US,
type "California", then switch to the United Kingdom: the state is dropped rather than
carried onto a British address it does not belong to. A stale state fails AVS on an
address the buyer got right, and nothing on screen would explain why. Under `'auto'` the
field stays visible, so the value is kept. The buyer can still see and correct it.
Hidden blocks still ride on the charge when you pass `defaultValues.billingDetails`.
That is how a merchant who already has the buyer's address keeps AVS without
drawing the fields. Omit `defaultValues` for a hidden block and that block is
empty on the charge, which costs address verification.
#### The Pay button ends in a paid state
Inside the frame the button runs idle → spinner → green tick, and **stays** on the
paid state: the charge is settled, so it does not fall back to "Pay" and invite a
second press on an intent that has already been charged.
That matters if you tear the element down on `onSuccess`. The buyer sees the tick only
for as long as your page leaves the element mounted — if you unmount immediately they
get no confirmation from us at all, so show your own. Both animations respect
`prefers-reduced-motion`.
#### What the element requires before it reports `complete`
A field the element **draws** must be filled before `onChange` reports
`complete: true`, and a lone space does not count as filled. So a form showing the
billing block reaches `complete` only once email, name, street, city, postal code and
country are all present — plus state or province in the countries whose form draws one
(US, CA, AU, IN, BR, MX). If you gate your own button on `complete`, it stays disabled
until then.
A field you **hide** is never required here: it comes from `defaultValues` instead, so
the short-form integration above is unaffected.
The check is presence, not shape. The element does not validate a postal code against
the country's format — per-country patterns refuse real addresses that the acquirer
would have accepted, and AVS is the authority on that question. The one exception is
`email`, which must at least look like an address, because a receipt has to go
somewhere.
Country is a **list**, not a text field: the buyer picks from all 249 ISO 3166-1
alpha-2 regions, named in their own language. Pass the same two-letter code in
`defaultValues.billingDetails.address.country`. A code the element does not recognise
is kept and shown as-is rather than corrected, since rewriting it would change the
address sent for AVS.
```js
wp.mountPaymentElement('#payment', {
clientSecret,
fields: { billingDetails: 'never' },
defaultValues: {
billingDetails: {
email: 'ada@example.com',
name: 'Ada Lovelace',
phone: '+44 20 7946 0958',
address: { line1: '1 Main St', city: 'London', postal_code: 'SW1A 1AA', country: 'GB' },
},
},
onSuccess: (r) => { /* … */ },
});
```
### What crosses into the frame
The client secret, your publishable key, the resolved `apiBase`, appearance, locale,
the billing flag, `defaultValues`, and optionally a customer session secret.
Never an amount. With `mode: 'payment'` the frame gets the mode instead of a secret, and
each attempt's client secret (from `createIntent`) when the buyer pays.
### What comes back out
Ready, a height, a completion state, cancel, an error, and on success the payload below.
No PAN, no CVV, no token, and no vault id. The one id that does cross,
`card_save.payment_method`, is a WorthyPayments `pm_…` payment-method id, not a gateway
vault id or token.
---
## `FrameSuccess`
```ts
interface FrameSuccess {
paymentIntentId: string;
status: 'succeeded' | 'processing';
transactionId: string | null;
charge?: FrameCharge; // named subset of GET /v1/charges/{id}, not the whole object
card_save?: FrameCardSave; // whether the card the buyer asked to save was saved
}
interface FrameCardSave {
status: 'pending' | 'saved' | 'skipped' | 'failed';
payment_method: string | null; // 'pm_...' when saved, else null
failure_code: string | null; // why, when skipped or failed
failure_message: string | null; // the gateway's own words, when it gave some
}
```
| Field | Meaning |
|---|---|
| `paymentIntentId` | The intent that was confirmed. |
| `status` | `'succeeded'` or `'processing'`. Never `'requires_payment_method'`. |
| `transactionId` | The charge id. Never `null` on `'succeeded'`. |
| `card_save` | Whether the card was saved, when the buyer ticked the save box. Exactly the four keys above, copied from confirm's `card_save`. See [`card_save`](#card_save). |
| `charge` | The receipt half of the charge: `amount`, `currency`, `status`, `paid`, `captured`, `amount_captured`, `amount_refunded`, `auth_code`, `processor_transaction_id` (gateway / NMI `transactionid`), `statement_descriptor`, `description`, `payment_method_type`, `metadata`, `created`, `billing_details`, `card` (`brand`, `last4`, `exp_month`, `exp_year`), and `failure_code` / `failure_message` on a decline. Not `processor`, `mid`, AVS/CVV, or `card.bin`. |
`charge` is **omitted** rather than null when the read did not answer,
`result.charge?.amount` gives `undefined`, not an object of nulls that reads as "there is
a charge" everywhere you test it.
It is a **named subset** of `GET /v1/charges/{id}`, not all of it. The server charge also
carries the merchant and acquirer facts, `mid`, `processor` (Admin → Acquirers → **Code**
of the acquirer **Payment Workflow** chose for this sale: `NMI` / `PAYARC` / `NUVEI` /
`MAVERICK` as configured, not a hardcoded enum), `processor_label`,
`network_transaction_id`, `arn`, `rrn`, the settlement block, and the `avs_result` /
`cvv_result` verdicts. Those answer a reconciliation question rather than "what did the
buyer just pay", and this payload lands in a document the buyer can read, so they stay
behind your secret key. Empty columns are omitted, not sent as `""`. Publishing
verification verdicts to the page is also how a card-testing script learns which addresses
pass. Those processors all ride one NMI security key; the sale is posted with that
acquirer's **Connector code (`processor_id`)** / charge code. **Connector codes** map raw
gateway keys onto the same row. Neither is the public `processor` field. See
[Charges](/developers/03-charges.md#which-acquirer-ran-it-and-what-the-gateway-said).
`card.bin` is absent for the same reason: bin + last4 is ten of the sixteen digits.
Your own **fraud** verdict is on `GET /v1/charges/{id}/risk` behind `payments:read`.
There is no instrument reference of any kind: no token, no vault id, no fingerprint, no
PAN, no CVV. `card_save.payment_method` is not one: it is a WorthyPayments `pm_…` id, and
charging it takes your secret key or that buyer's own customer session.
**For rendering, not for deciding.** The rule has not moved: fulfil from your server, on
the charge or the `charge.succeeded` webhook. A value that reached your page through a
browser is a value a browser could have edited. What changed is that showing "Paid $49.99
with Visa ••4242" no longer needs a second authenticated round-trip from a flow that is
already holding the outcome.
### `card_save`
Present when the buyer asked to save the card (the element's save box, see
[Saving a card for later](/developers/08b-payment-element.md#saving-a-card-for-later)) and the
intent `succeeded`. **Omitted**, never null, when saving was not requested, on any other
outcome, and when the result could not be read back (then `charge` is omitted too). The
payment's outcome never depends on it.
| `status` | Meaning |
|---|---|
| `'saved'` | The card is saved for the customer (newly, renewed, or it already was). `payment_method` is its `pm_…` id. |
| `'pending'` | The save may still be running, or its outcome is unknown. It can stay pending. |
| `'skipped'` | Not attempted. `failure_code`: `customer_missing` (the payment had no customer), `card_not_saveable` (not a fresh card), `payment_not_approved`. |
| `'failed'` | Attempted and failed; the payment stands. `failure_code` is the vault's reason (for example `VAULT_ADD_REFUSED`), `failure_message` the gateway's words when it gave some. |
**`pending` can stay pending, so do not wait on it in the browser.** A save that completes
after the confirm answered sends the `payment_method.attached` webhook only for a newly
saved card, `payment_method.updated` when it renewed a card the customer already had (same
card, new expiry), and no event at all when that card was already saved with the same
expiry. Settle a lasting `pending` on your server with
`GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`), never by waiting
for `payment_method.attached`.
**`payment_method` is the `pm_…` form.** Use it on your server: `POST /v1/charges` takes it
as `payment_method.payment_method_id`, and it matches the ids
`GET /v1/customers/{id}/payment_methods` returns. A browser confirm with a saved card takes
it too, with that buyer's customer session, as it takes the bare id
[`savedCards()`](#savedcardscustomersessionsecret) lists (no `pm_` prefix) for the same card.
`FrameCardSave` is not exported by name; type it as `FrameSuccess['card_save']`.
### `status: 'processing'`
The outcome is not known yet. Do not fulfil. Wait for the `charge.succeeded` webhook, or
retrieve the intent (`GET /v1/payment_intents/{client_secret}`): the read asks for the
outcome and never charges. A confirm the browser waited 55 seconds for (or your longer
`timeout`) ends the same way: the SDK retrieves the intent and reports what it reads.
`processing` is also what you get for any state the frame does not recognise, which is
deliberate. Reporting `succeeded` would tell a merchant to ship unpaid goods. Reporting a
decline would tell a buyer whose card *was* charged that it was not.
### Declines
A decline does not throw and does not reach `onSuccess`. It arrives on `onError` with
`type: 'card_error'`, `code: 'card_declined'` and `declineCode`, and the element **stays
mounted** so the buyer can try another card. The intent is still alive. An Apple Pay /
Google Pay decline arrives the same way.
`message` is the sentence the buyer already read, chosen by `declineCode` and in the
element's `locale`; the full table is in
[Payment Element, outcomes](/developers/08b-payment-element.md#3-handle-the-three-outcomes). Branch on
`code` and `declineCode`, never on `message`. Failures that are not declines arrive on
`onError` with their own `code` (for example `request_timeout`, or `amount_changed` on a
wallet payment); branch on `code`, not on `type`.
---
## `PaymentElementHandle`
```ts
interface PaymentElementHandle {
update(patch: {
clientSecret?: string; // clientSecret mounts only
amount?: number; // mode: 'payment' mounts only
currency?: string; // mode: 'payment' mounts only
appearance?: Appearance;
locale?: string;
defaultValues?: { billingDetails?: { email?: string; name?: string; phone?: string; address?: object } };
}): void;
submit(): void;
focus(): void;
destroy(): void;
}
```
| Method | Behaviour |
|---|---|
| `update(patch)` | Changes the client secret, appearance, locale, or `defaultValues` in place. Every field is optional; omitted fields are left alone. `fields` is mount-only. On a `mode: 'payment'` element it takes `amount` / `currency` instead of `clientSecret`; the wrong one for the mount throws `update_not_supported`. |
| `submit()` | Submits the form. Use it to drive the element from your own button. If the fields are incomplete, the frame shows a validation message instead (and a `mode: 'payment'` element does not call `createIntent`). |
| `focus()` | Moves keyboard focus to the card number field. No-op before `onReady`. |
| `destroy()` | Removes the iframe and its message listener. Safe to call twice; the second call does nothing. |
⚠️ **`destroy()` is permanent. There is no `unmount()`.** A destroyed element cannot be
re-attached; mount a new one. This matters in React: putting `destroy()` in an effect
cleanup means StrictMode's double-invoke, or any route change and return, leaves you with
a dead element. Tie it to real teardown, not to every re-render.
Calls after `destroy()` are ignored rather than throwing, so a late `update()` from an
in-flight effect cannot post a client secret into a frame that is already gone.
⚠️ **`focus()` does not work on iOS.** Safari only moves focus into a cross-origin iframe
in response to a real user gesture, so programmatic focus is silently ignored there. The
same limitation applies to Stripe. Do not build a flow that depends on it, on iOS the
buyer must tap the field.
On a `clientSecret` element, `update()` never takes an amount. When the cart changes, your
server writes the new total to the intent with `PATCH /v1/payment_intents/{id}`, and **the
client secret does not change**: a mounted element needs no `update()` at all. Pass a new
`clientSecret` only when you created a different intent. The price still never travels
through your page. See [When the cart changes](/developers/08b-payment-element.md#when-the-cart-changes).
On a `mode: 'payment'` element there is no intent yet, so `update({ amount })` is how the
wallet buttons learn the new total; they redraw with it. It stays on your page (the frame
never receives an amount), and the intent `createIntent` creates must carry the same total.
---
## `mountExpressCheckout(target, opts)`
Apple Pay and Google Pay buttons on their own, with no card form: on a product page, a
cart, or above your checkout form (Stripe's Express Checkout Element). The buyer chooses
the shipping address and the shipping method inside the sheet. Nothing exists on your
server until the buyer authorizes: `createIntent` then receives the buyer's details from
the sheet, so a guest can pay without typing anything on your page.
A wallet hands back an encrypted credential, never a readable card number, which is why
these buttons can sit on your page. They are drawn only where the device can pay, and only
when `GET /v1/wallet-config` reports the wallet on (see [Wallets](#wallets)).
```js
const express = wp.mountExpressCheckout('#express', {
amount: 2500,
currency: 'usd',
lineItems: [
{ label: 'Subtotal', amount: 2000 },
{ label: 'Standard shipping', amount: 500 },
],
shipping: {
methods: [
{ id: 'standard', label: 'Standard shipping', amount: 500, detail: '3-5 days', selected: true },
{ id: 'express', label: 'Express shipping', amount: 1500, detail: 'Next day' },
],
// Your server's total for that address: { amount, lineItems, methods } or { error }.
onAddressChange: (address) => postJson('/express/address', address),
// Your server's total with that method: { amount, lineItems }.
onMethodChange: (id) => postJson('/express/method', { id }),
},
// Place the order with the sheet's details and create its intent for the authorized total.
createIntent: async (payment) => (await postJson('/express/order', payment)).client_secret,
onReady: ({ applePay, googlePay }) => {
if (!applePay && !googlePay) document.querySelector('#express').hidden = true;
},
onSuccess: (result) => { window.location.href = '/thank-you'; },
onError: (error) => showMessage(error.message),
});
```
`target` is a CSS selector or an element; nothing matching throws `container_not_found`.
| Option | Type | Required | Default | Behaviour |
|---|---|---|---|---|
| `amount` | `number` | Yes | — | The total the sheet opens with, in minor units (`2500` for $25.00). |
| `currency` | `string` | Yes | — | ISO 4217, such as `'usd'`. |
| `lineItems` | `{ label, amount }[]` | No | one "Total" row | The sheet's rows, minor units. A discount is negative. |
| `createIntent` | `(payment: ExpressPayment) => Promise` | Yes | — | Called once the buyer authorized. Create the payment intent for exactly the total the sheet showed last and resolve its `client_secret`. A rejection reaches `onError` as `intent_creation_failed`, and the sheet shows the failure. |
| `onSuccess` | `(result: FrameSuccess) => void` | Yes | — | The payment's outcome, the same [`FrameSuccess`](#framesuccess) as the Payment Element's. |
| `shipping` | `ExpressShipping` | No | — | The order ships: the sheet asks for the address and offers the methods. See [Shipping in the sheet](#shipping-in-the-sheet). |
| `recurring` | `WalletRecurring` | No | — | The payment starts a subscription. See [Subscriptions on the wallet buttons](#subscriptions-on-the-wallet-buttons). |
| `paymentMethods` | `{ applePay?, googlePay? }` | No | `'auto'` | `'never'` leaves that wallet out. |
| `walletButtons` | `WalletButtonsOptions` | No | — | As on the Payment Element ([Wallet buttons](#wallet-buttons)). |
| `borderRadius` | `number` | No | `4` | The buttons' corner radius, px. |
| `locale` | `string` | No | `'en'` | `'es'` for Spanish. |
| `onReady` | `({ applePay, googlePay }) => void` | No | — | Which buttons were drawn. Both `false` (no wallet on this device or account): hide your container. |
| `onError` | `(error: WorthyPaymentsError) => void` | No | — | A payment that did not go through, or a button that could not be drawn (`wallet_error`). |
| `onCancel` | `() => void` | No | — | The buyer closed the sheet. The sheet's figures go back to the mounted ones. Your server may still hold the address the buyer tried, so read your total again and call `update()`. |
### `ExpressPayment`
What `createIntent` receives: everything a guest order needs.
```ts
interface ExpressPayment {
wallet: 'apple_pay' | 'google_pay';
billing: { // as far as the buyer shared them
email?: string; name?: string; phone?: string;
address?: { line1?: string; line2?: string; city?: string; state?: string; postal_code?: string; country?: string };
};
shipping?: { // only with `shipping`
name?: string; phone?: string;
address: { line1?: string; line2?: string; city?: string; state?: string; postal_code?: string; country?: string };
};
shippingMethod?: string; // the id of the method the authorized total includes
}
```
The sheet asks the buyer for an email, name, phone and billing address. Apple Pay and Google
Pay release the shipping name and street lines only once the buyer authorizes, so they
arrive here and never in the shipping callbacks.
### Shipping in the sheet
```ts
interface ExpressShipping {
methods: ShippingMethod[]; // for the address you know; may be []
onAddressChange(address: ShippingAddress, sheet: { methodId?: string }): Promise;
onMethodChange(methodId: string): Promise;
}
interface ShippingMethod { id: string; label: string; amount: number; detail?: string; selected?: boolean }
interface ShippingAddress { country: string; state: string; city: string; postal_code: string }
interface ShippingUpdate { amount: number; lineItems?: { label: string; amount: number }[]; methods?: ShippingMethod[]; error?: string }
```
- `onAddressChange` receives the four fields the wallets release while the sheet is open,
and the method the sheet shows selected (`sheet.methodId`). Answer the total for that
address and its methods. The wallet keeps the selected method when the new address offers
it, so price that one; when it is not offered, mark the one your total includes
`selected`.
- `onMethodChange` answers the total with that method.
- An `error`, or an empty `methods` list, says the order cannot ship there. Google Pay
shows it on the address. Apple Pay cannot show one from these callbacks, so the payment
is refused when the buyer authorizes (`shipping_unavailable`) and `createIntent` is not
called. Either way no order is placed for that address.
- A callback that throws, takes longer than 10 seconds (Google's limit), or answers no
positive whole `amount` is treated the same way.
- Google Pay has no price field for a method, so the SDK writes the price into the label
("Standard shipping: $5.00"). Google offers a method choice only when `methods` was not
empty at mount; with none it asks for the address alone and the total follows it.
- Apple Pay's subscription sheet takes no shipping methods. With `recurring` and
`shipping` both set, only Google Pay is drawn.
### The authorized total is the charged total
`createIntent` must create the intent for the total the sheet showed last: the `amount`
your latest callback answered, or the mounted one. Before confirming, the SDK reads the
intent, and a different total or currency charges nothing: `onError` receives
`amount_changed`. Read your total again and call `update()`.
### `ExpressCheckoutHandle`
| Method | Behaviour |
|---|---|
| `update({ amount?, currency?, lineItems?, shippingMethods? })` | Redraws the buttons with these figures. Call it whenever your cart changes. `shippingMethods` only on a mount with `shipping`; otherwise it throws `update_not_supported`. |
| `destroy()` | Removes the buttons. |
Thrown at mount or by `update()`: `invalid_amount`, `invalid_currency`,
`missing_create_intent`, `missing_on_success`, `invalid_shipping`,
`invalid_shipping_methods`, `invalid_line_items`, `invalid_payment_methods`,
`container_not_found`. Reaching `onError`: `wallet_error`, `shipping_unavailable`,
`intent_creation_failed`, `invalid_client_secret` (what `createIntent` resolved is not a
payment intent's client secret), `amount_changed`, and the confirm codes of
[Errors](#errors).
---
## `savedCards(customerSessionSecret)`
```ts
savedCards(customerSessionSecret: string): Promise
```
Returns the saved cards of the one buyer a customer session names, for rendering your own
chooser. The element shows them by itself when you pass `customerSessionSecret` to
`mountPaymentElement`, so call this only when you want to build the picker yourself.
```ts
interface SavedCard {
id: string; // the bare payment-method id (no 'pm_' prefix)
card: {
brand: string; // '' when upstream did not supply one
last4: string; // '' when upstream did not supply one
exp_month: number | null;
exp_year: number | null;
};
}
```
Brand, last4 and expiry are enough to render a chooser. Vault ids and raw instrument
tokens never reach the browser: the browser picks a card by *our* id, and resolving it to
a real instrument happens server-side, where the key that can move money lives.
Pass `id` unchanged as a browser confirm's saved card. It carries no `pm_` prefix, unlike
`GET /v1/customers/{id}/payment_methods` and `card_save.payment_method`; confirm takes the
`pm_…` form of the same card too.
Resolves to `[]` when the customer has no saved cards.
### Customer sessions
Reading a customer's cards normally needs `customers:read`, and publishable keys cannot
hold that scope: `pk_` is public by construction, and that scope lists your whole customer
book. So your server names the one customer this buyer is. A configured environment
returns `201` with `client_secret`. `400 customer_sessions_unavailable` means the session
HMAC secret is not injected. The route exists and fails closed.
```bash
curl https://api.worthypayments.com/v1/customer_sessions \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8"}'
```
```json
{
"object": "customer_session",
"customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8",
"client_secret": "...",
"expires_at": 1786000000
}
```
`customer` accepts the `cus_` id or the bare uuid, and is answered as `cus_`.
**The `client_secret` from that response is what you pass as `customerSessionSecret`.**
The name differs on the wire, and that trips people:
| Response field | SDK parameter |
|---|---|
| `client_secret` | `customerSessionSecret` |
`expires_at` is a Unix timestamp. The session is a signed 30-minute grant scoped to one
customer, one merchant and one mode. A token minted for another merchant is rejected even
though its signature is valid.
`customer` must be non-empty and must not contain `|`; either returns
`parameter_invalid`.
---
## `ping()`
```ts
ping(): Promise
```
Verifies the key and reports its resolved mode. A successful call is itself the "ok".
```ts
interface PingResult {
object: 'ping';
mode: 'test' | 'live';
key_type: string; // lowercased, e.g. 'publishable'
merchant_account_id: string;
api_version: string;
}
```
```js
await wp.ping();
// { object: 'ping', mode: 'live', key_type: 'publishable',
// merchant_account_id: 'cmt3yfavu00013iokngc9rjtw', api_version: '2026-01-01',
// scopes: ['payments:charge'], unrestricted: false }
```
There is no `ok` boolean. `scopes` IS returned, but for a publishable key it is always
exactly `['payments:charge']`. The server narrows every `pk_` to that regardless of what
is stored on it, so nothing a page did not already know is disclosed. Mirrors [`GET
/v1/ping`](/developers/11-health.md).
---
## `getFingerprint()`
```ts
getFingerprint(): Promise
```
A device fingerprint computed in the browser and cached in `localStorage` for 24 hours.
The element attaches it for you. Call this only when you build a charge server-side and
want the signal.
Returns `null` when fingerprinting is unavailable, which is not fatal.
---
## `getKountSessionId()`
```ts
getKountSessionId(): Promise
```
The Kount device-collection session id, or `null` when collection is disabled or
unreachable. The element attaches it on its own.
Never throws and never blocks. Every failure path resolves to `null` and the payment
proceeds without a device signal. See
[Device data collection](/developers/24-device-data-collection.md).
---
## `getDeviceSession()`
```ts
getDeviceSession(): Promise
```
A device session id (`ds_…`): this browser's own connection (IP, user agent, fingerprint),
recorded by `POST /v1/device_sessions` with your publishable key. Send it to your server and
put it on [`POST /v1/charges`](/developers/03-charges.md) as `device_session`, and a charge your server
sends carries the buyer's IP and browser instead of your server's. The element needs none: it
confirms from the buyer's browser.
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, and concurrent calls share one request.
Resolves `null` on any failure, and a charge without a session goes through as before. See
[Device data collection](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser).
---
## Appearance
```ts
interface Appearance {
theme?: 'default' | 'night' | 'flat'; // 'stripe' is a deprecated alias for 'default'
variables?: Record;
rules?: Record>;
}
```
You name variables and we apply them. Themes are applied first, so your own variables
override the preset: picking `night` and then naming `colorPrimary` gives you the dark
palette with your accent.
```js
appearance: {
theme: 'night',
variables: {
colorPrimary: '#7C3AED',
borderRadius: '12px',
fontFamily: 'Inter, sans-serif',
},
rules: {
'.Input': { boxShadow: 'none' },
'.Input:focus': { borderColor: '#7C3AED' },
},
}
```
An unknown **theme** name is reported as ignored. An unknown **variable** or property name
is dropped silently, so a typo costs you the styling and not the checkout.
### Variables
`colorPrimary`, `colorBackground`, `colorText`, `colorTextSecondary`, `colorDanger`,
`colorSuccess`, `colorBorder`, `colorIcon`, `colorPrimaryText`, `fontFamily`,
`fontSizeBase`, `fontSizeSm`, `fontWeightNormal`, `fontWeightMedium`, `borderRadius`,
`borderWidth`, `spacingUnit`, `spacingGridRow`, `boxShadow`, `focusBoxShadow`,
`focusOutline`.
Each maps to a CSS custom property: `colorPrimary` becomes `--wp-color-primary`.
`colorPrimaryText` is the Pay button label (default white). `boxShadow` is the
resting elevation on `.Root` (the outer container), not on every input. Input
shadows go through `rules['.Input']`.
**The Pay button.** When you name `colorPrimary`, the button is filled with it (a slight
brightening on hover). A theme's own primary does not repaint the button: `night`'s is a
light accent that white button text would be unreadable on. `rules['.Button']` and
`rules['.Button:hover']` with `backgroundColor` win over both. Its wording and whether it
is drawn at all are the `payButton` option, not appearance.
The button is always in one of four states, and carries one class for each state but the
first: ready to pay (no class), `.Button--disabled` (the form is not complete yet),
`.Button--loading` (the payment is in progress) and `.Button--paid`. A state's rule wins
over `.Button` and `.Button:hover`, whatever order you write them in, so a loading button
under the pointer still looks loading. A `backgroundColor` on a state replaces the default
gradient for that state.
```js
rules: {
'.Button--disabled': { opacity: '0.6' },
'.Button--loading': { backgroundColor: '#5B21B6' },
}
```
**Fonts.** The frame is its own document on its own origin, so it cannot use a font your
page loads. It serves **Inter, Roboto, Open Sans, Lato, Montserrat and Poppins** itself:
name one of those in `fontFamily` (in any case, quoted or not) and the form renders in
it. Any other family falls back down your stack, so end it with a generic family
(`sans-serif`).
### Rule selectors
`.Root`, `.Input`, `.Input:focus`, `.Input--invalid`, `.Input::placeholder`, `.Label`,
`.Error`, `.Block`, `.Button`, `.Button:hover`, `.Button--disabled`, `.Button--loading`,
`.Button--paid`, `.SavedItem`, `.SavedItem--selected`, `.SavedItem:hover`, `.Hint`,
`.SectionTitle`, `.Field`, `.Row`, `.Checkbox`, `.CheckboxInput`, `.CheckboxLabel`,
`.CardBrand`. A selector outside that list is dropped, so a rule cannot reach arbitrary
DOM.
| Selector | Is |
|---|---|
| `.Input::placeholder` | The placeholder text in every field |
| `.Checkbox`, `.CheckboxInput`, `.CheckboxLabel` | The "save this card" row, its checkbox and its text (`fields.savePaymentMethod: 'auto'`). Colour the tick with `accentColor` on `.CheckboxInput`. |
| `.CardBrand` | The card network mark drawn in the card-number field |
| `.SavedItem:hover` | A saved card under the pointer. It does not apply to the selected card, so that one keeps its `.SavedItem--selected` look. |
| `.Button--disabled`, `.Button--loading`, `.Button--paid` | The Pay button's states (see [The Pay button](#variables) above) |
Rules are applied in the order of this list, not the order you write them, so a state
always wins over what it modifies.
### Rule properties
`color`, `backgroundColor`, `border`, `borderColor`, `borderStyle`, `borderWidth`,
`borderRadius`, `boxShadow`, `outline`, `outlineColor`, `outlineOffset`, `padding` (and
the four sides), `margin` (and the four sides), `fontFamily`, `fontSize`, `fontWeight`,
`fontStyle`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration`, `opacity`,
`transition`, `width`, `minWidth`, `maxWidth`, `height`, `minHeight`, `maxHeight`,
`overflow`, `overflowX`, `overflowY`, `textAlign`, `gap`, `accentColor`.
Box-size **inside** the frame is allowed: `rules['.Root']` can set `maxWidth`,
`maxHeight` and `overflowY: 'auto'` so the form narrows or scrolls. Leaving the
flow is not. `position`, `display`, `flex`, `transform`, `zIndex` are dropped:
those are how a compromised appearance would paint over the host page.
### What appearance does not control
| You want | You do this |
|---|---|
| Iframe **box** on the page | Size the host `#payment`. The iframe is 100% of that box. |
| Form **narrower than the host** | `rules['.Root']`: `{ maxWidth: '420px', marginLeft: 'auto', marginRight: 'auto' }` |
| **Scroll** inside a short column | `rules['.Root']`: `{ maxHeight: '480px', overflowY: 'auto' }` |
| Checkout **column card** (page-level drop shadow) | Style your own wrapper. The iframe canvas is transparent on purpose. |
| **No border** on a block | `rules['.Input']`: `{ border: 'none', boxShadow: 'none' }` |
### Value rules
Values are checked against a positive allowlist rather than a set of blocked patterns.
| Rule | Detail |
|---|---|
| Character set | Only `a-z A-Z 0-9`, whitespace, and `# % ( ) . , + - _ / ' "` |
| Length | 200 characters maximum |
| Comments | `/*` and `*/` are refused outright |
| Functions | Only `rgb`, `rgba`, `hsl`, `hsla`, `var`, `calc`, `min`, `max`, `clamp` |
| `var()` | May only reference `--wp-` names |
Anything outside that alphabet is dropped. That covers escaping the declaration (`;`,
`{`) as well as every spelling of a network request: `url()`, `image-set()`, `src()`,
`\75 rl()`, `u/**/rl()`, `attr()`.
`backgroundImage` is not settable at all. Every use of it is an outbound request from
inside the payment frame, and aimed at `.Input:focus` or `.Input--invalid` that request
becomes a beacon reporting the moment a buyer reached the card field, or the moment a
number failed its checksum.
---
## Wallets
Apple Pay and Google Pay mount on the **merchant page, directly above the card iframe**.
The same place payment links put them. Wallets are not card data, so they stay on your
origin (the one Apple and Google already know). The PAN stays in the iframe. The Payment
Element needs no separate wallet mount and nothing to wire. For the buttons without a card
form (a product page, a cart, shipping chosen in the sheet), use
[`mountExpressCheckout()`](#mountexpresscheckouttarget-opts).
The sheet total is loaded from the intent, so it is the server amount. A buyer dismissing
the sheet fires `onCancel`.
**They may be off.** `GET /v1/wallet-config` often returns `provider: "NONE"` with
`applePay: false` and `googlePay: false`. That is the default, not an error: the SDK
mounts no buttons. Enable in the Merchant Dashboard under **Settings → Checkout Settings →
Digital Wallets** (Test: **Create test wallet** on that page, then the switches; Live:
support must provision the wallet merchant first). Mount a button only on a literal
`true`. Device/browser/wallet state still decides whether a button appears on a given
buyer. An absent wallet section on a Linux desktop is the correct outcome even when the
account flag is on.
### Wallet buttons
Apple and Google draw their own buttons (Apple as a shadow-DOM component, Google as an
iframe), so neither takes your page's CSS or `appearance`. `walletButtons` sets the presets
each one offers:
```js
walletButtons: {
applePay: { theme: 'white-outline', type: 'subscribe' },
googlePay: { theme: 'white', type: 'subscribe' },
height: 44,
}
```
| Option | Values | Default |
|---|---|---|
| `applePay.theme` | `black`, `white`, `white-outline` | `black` |
| `applePay.type` | `add-money`, `book`, `buy`, `check-out`, `continue`, `contribute`, `donate`, `order`, `pay`, `plain`, `reload`, `rent`, `set-up`, `subscribe`, `support`, `tip`, `top-up` | Apple's own |
| `googlePay.theme` | `black`, `white` | `black` |
| `googlePay.type` | `book`, `buy`, `checkout`, `donate`, `order`, `pay`, `plain`, `short`, `subscribe` | Google's own |
| `height` | 40 to 55 (px), both buttons | `48` |
A value outside its list, or a height outside 40-55, is ignored and that setting keeps its
default. The two providers spell some types differently (`check-out` for Apple,
`checkout` for Google). The corner radius follows `appearance.variables.borderRadius`, and
the "Or pay with card" line under the buttons uses your page's font.
### Subscriptions on the wallet buttons
```ts
interface WalletRecurring {
description: string; // shown on the Apple Pay sheet
billingAgreement: string; // the terms, shown before the buyer authorizes
managementUrl: string; // https page to manage or cancel
regularBilling: {
label: string;
amount: number; // minor units, above zero
intervalUnit?: 'day' | 'week' | 'month' | 'year'; // default 'month'
intervalCount?: number; // default 1
startDate?: string | Date; // default now
};
trialBilling?: { label: string; amount: number; startDate?: string | Date };
}
```
With `recurring` set, the Apple Pay sheet opens as Apple's recurring payment request
(Google Pay's web API has an equivalent, `recurringTransactionInfo`, which the SDK does not
send yet), and a wallet payment sends
`save_payment_method: true` on its confirm, so `FrameSuccess.card_save` reports the
outcome. Google Pay then offers only the cards it hands back as the card itself
(`PAN_ONLY`), which are saved and can renew. An Apple Pay payment answers
`card_save.status: 'skipped'`, `failure_code: 'card_not_saveable'` for now (its device or
merchant token cannot be saved yet), so collect a card for renewals, or leave Apple Pay out
of a subscription with `paymentMethods: { applePay: 'never' }` on the express element.
The intent needs a `customer`. A malformed value reaches `onError` and the Apple Pay button
is not drawn. `WalletRecurring` and `RecurringIntervalUnit` are exported types. Evervault
wallet rail only. Full walkthrough:
[Payment Element](/developers/08b-payment-element.md#subscriptions-paid-by-wallet).
---
## 3-D Secure
When a charge is stepped up, the element drives 3-D Secure itself: it mounts the challenge
once, inside the element's own frame (the frame grows to fit it), then resubmits
automatically. If the issuer authenticates without a challenge, nothing is shown. There
is nothing to wire.
Paygeyt has no `return_url` field. Stripe uses that for a full-page redirect after 3DS;
here the element stays on the checkout page, so `onSuccess` is the return path.
Driving 3DS yourself, from a native app or a server-side flow, is
[3-D Secure](/developers/07-3ds.md).
---
## Domain allowlist
The element refuses to render unless the embedding origin is on your key's allowed
domains. `frame-ancestors` is computed per request from that list (via
`GET /v1/element-frameable`), and the browser enforces it. A refusal shows as a
**blank frame**, not an error, which is the shape most first-run problems take here.
The origin under test is the `Origin` header. Query `origin` is optional and must not
carry a loopback URL on the public edge (`?origin=http://localhost…` is HTML 403; omit the
query and send `Origin` instead). The hosted SDK already does that; you do not put
`localhost` in the iframe URL. See [Local development](#local-development) and [Errors,
loopback in the URL](/developers/10-errors.md#loopback-in-the-url).
**Important:** An empty allowlist does not open the element. Framing fails closed: no domains
configured → `frame-ancestors 'none'` → blank iframe. That is the opposite of the
charge API's DomainGuard, which treats an empty list as "no origin lock" for
server-to-server calls. For the Payment Element you must have the list set.
Wildcards match a single label. `*.shop.com` covers `www.shop.com` and not
`a.b.shop.com`, so list each host you actually embed on. A wildcard does **not** cover
the apex either: `*.shop.com` leaves `shop.com` refused, so list both if buyers check
out at both. The same list decides which origins may charge with the key once it is
non-empty.
Set the list when you create the key in **Settings → Developers → API keys** (**Allowed
domains**). Update it on the key later without rolling the secret. A new checkout origin
does not need a new `pk_`.
---
## Errors
`onError` receives a `WorthyPaymentsError`:
```ts
class WorthyPaymentsError extends Error {
readonly type: string;
readonly code: string;
readonly status?: number; // HTTP status, for transport failures
readonly declineCode?: string;
}
```
### Codes from the element
| Code | Cause |
|---|---|
| `card_declined` | The issuer declined. The element stays mounted for a retry. |
| `payment_failed` | The confirm threw for a reason that is not a decline. |
| `element_error` | The frame reported an error with no code of its own. |
| `intent_creation_failed` | `mode: 'payment'`: your `createIntent` rejected; `message` is your error's. Nothing was charged and the buyer can try again. |
| `invalid_client_secret` | `mode: 'payment'`: `createIntent` resolved something that is not a client secret. Nothing was charged. |
### Codes from confirm
| Code | Cause |
|---|---|
| `parameter_unknown` | You sent `amount` or `currency` to confirm. The intent fixed those. Refused, not ignored. |
| `parameter_missing` | No instrument. Pass exactly one. |
| `parameter_invalid` | Two instruments at once. Pass exactly one. |
| `secret_key_in_browser` | An `sk_` reached the browser. Use the publishable key and a client secret. |
| `resource_missing` | No such intent for this key's merchant and mode. |
Two instruments is refused rather than resolved. Only one can be charged, so the other
would be dropped silently and you could bill something you did not intend.
### Codes thrown at construction or mount
| Code | Thrown by | Cause |
|---|---|---|
| `missing_key` | Factory | No key, or not a string. |
| `invalid_key` | Factory | Key does not start with `pk_`. |
| `secret_key_in_browser` | Factory | Key starts with `sk_`. |
| `container_not_found` | `mountPaymentElement` | No element matches `target`. |
| `client_secret_with_mode` | `mountPaymentElement` | Both `clientSecret` and `mode` were passed. Pass one. |
| `invalid_mode` | `mountPaymentElement` | `mode` is not `'payment'`. |
| `invalid_amount` | `mountPaymentElement`, `update()` | With `mode: 'payment'`, `amount` is not a positive integer in the smallest currency unit. |
| `invalid_currency` | `mountPaymentElement`, `update()` | With `mode: 'payment'`, `currency` is not a three-letter code. |
| `missing_create_intent` | `mountPaymentElement` | `mode: 'payment'` without a `createIntent` function. |
| `update_not_supported` | `update()` | `amount` / `currency` on a `clientSecret` element (PATCH the intent instead), or `clientSecret` on a `mode: 'payment'` element. |
| `invalid_client_secret` | `mountPaymentElement` | `clientSecret` is not shaped like one. Pass the `client_secret` from `POST /v1/payment_intents`, either `pi_..._secret_` or the bare token. A placeholder used to mount a full, working-looking card form and only fail when the buyer pressed Pay. **Shape only:** a well-formed secret for an intent that is not yours still fails at confirm, where ownership can actually be checked. |
| `invalid_frame_origin` | `mountPaymentElement` | `elementOrigin` is not an absolute URL. |
| `insecure_frame_origin` | `mountPaymentElement` | `elementOrigin` is not https. |
| `same_origin_frame` | `mountPaymentElement` | `elementOrigin` equals your own origin. |
Full catalogue: [Error codes](/developers/30-error-codes.md).
---
## TypeScript
Types are re-exported from the package entry point:
```ts
import WorthyPayments, {
type Appearance,
type FrameSuccess,
type PingResult,
type WorthyPaymentsOptions,
WorthyPaymentsError,
} from '@worthypayments/js';
```
`PaymentElementOptions`, `PaymentElementHandle`, `PaymentElementUpdate` (what `update()`
takes) and `SavedCard` resolve from the same import:
```ts
import type {
PaymentElementOptions,
PaymentElementHandle,
PaymentElementUpdate,
SavedCard,
} from '@worthypayments/js';
```
The express element's types resolve from it too: `ExpressCheckoutOptions`,
`ExpressCheckoutHandle`, `ExpressCheckoutUpdate`, `ExpressPayment`, `ExpressBilling`,
`ExpressShipping`, `ShippingMethod`, `ShippingAddress`, `ShippingSheet`, `ShippingUpdate`, `SheetLineItem`
and `WalletsDrawn`.
The package deliberately exports nothing beyond the types listed here. If a name you
expect does not resolve, it belongs to the internals rather than the integration.
### Not part of the public API
`InitPayload`, `UpdatePayload` and `IntentReply` are exported because the iframe imports
them from the same package. They are the internal postMessage contract between the parent and the frame.
They will change without a major version, and nothing in an integration should reference
them.
### Script-tag statics
Script-tag users get `WorthyPayments.WorthyPaymentsError` on the global factory, which is
what you need to recognise an SDK error.
The bundle also carries `isChargeError`, `isRequiresAction` and `isSucceeded` as statics.
They narrow a raw `POST /v1/charges` response and predate the element, which reports its
outcome through `onSuccess` and `onError` instead. You will not need them in an element
integration, and they are not importable from the package in TypeScript.
---
## Putting the element in a modal
⚠️ **Escape will not close your dialog by itself.** A keypress inside a cross-origin
iframe never reaches the embedding page, so once the buyer clicks into the card number
(the field they spend the longest in) your `keydown` listener stops hearing anything. The
dialog looks broken, and no amount of debugging your own handler explains it.
`onEscape` is the fix:
```js
wp.mountPaymentElement('#payment', {
clientSecret,
onEscape: () => closeModal(),
onSuccess: (r) => { /* … */ },
});
```
Two more things worth knowing for modals:
- **Mount after the dialog is in the DOM.** Mounting into a detached node throws
`container_not_in_document`; an iframe inside a node that is not in the document never
loads, so it fails immediately rather than hanging.
- **`focus()` will not work on iOS**, so do not rely on auto-focusing the card field when
the dialog opens.
## Content-Security-Policy
If you send a CSP (and a checkout page should) the element needs these. It is served from
its own origin precisely so that card data never enters your page's scope, which means
`frame-src` is not optional:
```
frame-src https://element.worthypayments.com
script-src https://app.worthypayments.com
connect-src https://api.worthypayments.com
```
A page that loads the SDK from `app.paygeyt.com` uses the paygeyt.com hosts instead
(`element.paygeyt.com`, `app.paygeyt.com`, `api.paygeyt.com`); see
[Two domains](/developers/README.md#two-domains).
⚠️ **Miss `frame-src` and the form never appears at all**. No `onError`, because the
browser blocks the frame before any of our code runs inside it. What you get instead is a
CSP violation in the console and, ten seconds later, `element_load_failed`. Check CSP
first when the element is blank.
Add these only for the features you actually use:
| Feature | Directive |
|---|---|
| Apple Pay | `script-src https://applepay.cdn-apple.com` |
| Google Pay | `script-src https://pay.google.com` and `frame-src https://pay.google.com` |
| Apple Pay / Google Pay (the SDK loads Evervault on your page to mount them), or a 3DS challenge you run yourself on your page | `script-src https://js.evervault.com`, `frame-src https://*.evervault.com`, `connect-src https://*.evervault.com` |
| Kount device data | `frame-src https://*.kaptcha.com` |
3-D Secure through the Payment Element needs nothing on your CSP: the challenge opens
inside the element's frame, whose own policy already allows Evervault.
You do not need to allowlist bank domains: the issuer's ACS page loads inside
Evervault's frame, which `https://*.evervault.com` already covers.
**Cross-origin isolation (COOP/COEP) breaks the element**, as it does Stripe's. If your
page sets `Cross-Origin-Embedder-Policy: require-corp`, the frame will not load.
## Safari and Apple Pay: device checklist
Skip this unless `GET /v1/wallet-config` reports `applePay: true`. Automating it is not
reliable, so run it by hand before calling wallet support done on a release.
1. The domain allowlist includes the host that embeds the element.
2. CSP and frame layout allow `applepay.cdn-apple.com`.
3. The iframe has `allow="payment"` (the SDK sets this; confirm nothing strips it).
Express wallets still mount on the merchant page above that iframe.
4. On a real iPhone in Safari: the Apple Pay button appears, the sheet opens, the amount
matches the intent, cancelling fires `onCancel`, and success returns the intent id.
5. On desktop Safari with no wallet configured: the unavailable hint shows and nothing
crashes.
---
## Related
- [Payment Element walkthrough](/developers/08b-payment-element.md)
- [Authentication](/developers/02-authentication.md)
- [3-D Secure](/developers/07-3ds.md)
- [Errors](/developers/10-errors.md) and [Error codes](/developers/30-error-codes.md)
- [Webhooks](/developers/16-webhooks.md) and [Events](/developers/19-events.md)
- [Device data collection](/developers/24-device-data-collection.md)
- [Going live](/developers/21-going-live.md)
========================================================================
# WooCommerce
The **WorthyPayments for WooCommerce** plugin (v2) takes card, Apple Pay and Google Pay
payments in your WooCommerce store through the [Payment Element](/developers/08b-payment-element.md).
The buyer types the card into our iframe, where Evervault encrypts it, and 3-D Secure runs
inside the same frame. **No card data reaches WordPress or your server**, so your store
stays on PCI SAQ A.
It works on the classic checkout, the Checkout block, the pay-for-order page, and for
headless clients that check out through the WooCommerce Store API (a mobile app, or a
dashboard of your own). Signed-in buyers can save cards, at checkout or from **My account →
Payment methods**, and [WooCommerce Subscriptions](#woocommerce-subscriptions) renewals charge
the saved card: a subscription always saves the card it starts with, a $0 free trial included.
Optionally: [Express checkout](#express-checkout), Apple Pay and Google Pay buttons on
product, cart and checkout pages with shipping chosen in the wallet sheet,
[tax from your WorthyPayments tax rates](#tax-rates-from-worthypayments), and a WP-CLI
command that [moves your NMI plugin's subscriptions](#moving-subscriptions-from-your-nmi-plugin)
to WorthyPayments.
Requires WordPress 6.2+, WooCommerce 8.0+ and PHP 7.4+; tested up to WordPress 7.1 and
WooCommerce 11.2. HPOS (High-Performance Order Storage) is supported. The plugin ships a
translation template, `languages/worthypayments.pot` (text domain `worthypayments`).
---
## Install
1. Download `woocommerce-worthypayments.zip` from your dashboard
(`/plugins/woocommerce-worthypayments.zip`). It is not on the WordPress.org directory; if
you cannot find it, contact support.
2. WordPress admin → **Plugins → Add New → Upload Plugin** → choose the zip → **Install Now**
→ **Activate**.
3. **WooCommerce → Settings → Payments → WorthyPayments**.
The v1 plugin (`woocommerce-tailoredpay`) is a different plugin folder. Deactivate and delete
it once v2 is configured; the two do not share settings.
**Deleting the plugin** (Plugins → Delete) removes its settings, its caches and its scheduled
jobs, nothing else. Orders and their payment details, subscriptions and the buyers' saved
cards stay, so payments remain refundable and disputes answerable if you install it again.
The webhook endpoint registered for this site stays at WorthyPayments too: remove it in
Dashboard → Developers → Webhooks if the store will not come back.
---
## Configure
| Setting | What to enter |
|---|---|
| **Enable** | Turn the method on. |
| **Title / Description** | What the buyer sees in the payment list. |
| **Test mode** | On: the test keys are used. Off: the live keys. |
| **Test / Live publishable key** | `pk_test_…` / `pk_live_…`. Loaded into the checkout page. |
| **Test / Live secret key** | `sk_test_…` / `sk_live_…`. Stays on your server. |
| **Test / Live webhook signing secret** | Filled in for you by **Webhook set-up**, or from the endpoint you register (see [Webhooks](#webhooks)). |
| **Webhook set-up** | **Set up the webhook for me**, on by default: saving with a mode's signing secret empty registers this site's endpoint in that mode and saves its secret. See [Webhooks](#webhooks). |
| **Payment attempts per hour** | Card-testing protection, `10` by default, `0` for off. See [Card testing](#card-testing). |
| **Capture** | `Automatic` (default) or `Manual`. See [Capture](#capture). |
| **Express checkout** | Off by default: Apple Pay and Google Pay buttons on product, cart and checkout pages. See [Express checkout](#express-checkout). |
| **Tax rates** | `WooCommerce's own tax rates` (default) or `My WorthyPayments tax rates`. See [Tax rates from WorthyPayments](#tax-rates-from-worthypayments). |
| **Payment form look** | `Match my theme` (default) or `WorthyPayments default`. See [Appearance](#appearance). |
| **Accent colour / Corner radius** | Optional. Used instead of what the form takes from your theme. |
| **Debug log** | Writes API calls to **WooCommerce → Status → Logs**, source `worthypayments`. |
Both keys of the active mode must be filled in, or the method does not appear at checkout.
The plugin checks the prefixes when you save, then checks every key that is filled in with
[`GET /v1/ping`](/developers/11-health.md) and says what it found at the top of the screen:
- a key our API refuses (invalid, expired, revoked), or a key of the other mode;
- a secret key that lacks one of the scopes below (each missing scope is named);
- a publishable key whose allowed domains do not list this site's hostname (the plugin pings
it with this site's `Origin`), so the card form would not load;
- with **Webhook set-up** on, what it did with the webhook endpoint, or that the endpoint for
this site does not send an event the plugin uses;
- while Apple Pay is on for your account, whether this site serves Apple's domain
verification file (see [Apple Pay on your domain](#apple-pay-on-your-domain)).
A key that cannot be checked right now (our API unreachable) is saved anyway, and the screen
says so.
While the method is on in test mode, every admin screen shows a **WorthyPayments is in test
mode** notice to shop managers, with a link to turn it off.
**Allowed domains.** The publishable key's allowed domains must list every hostname your
checkout runs on, or the payment form stays blank. See
[Allow your domain](/developers/08b-payment-element.md#before-anything-renders-allowlist-your-domain).
**Secret key scopes.** `payment_intents:create`, `payments:read`, `payments:charge`
(saved-card payments, renewals, capture and void), `refunds:create`, `customers:read`,
`customers:write` (customers, and the setup intents that save a card with no payment) and
`customer_sessions:create` (saved cards), and `tax:read` when
**Tax rates** is set to WorthyPayments. For **Set up the webhook for me**, also
`webhooks:read` and `webhooks:write`. See [Authentication](/developers/02-authentication.md).
An order keeps the mode it was paid in: refunds and captures of a test order always use the
test keys, even after you switch the store to live.
---
## How a payment works
1. The checkout page shows the Payment Element in the payment list, under the plugin's title:
the card form, and Apple Pay / Google Pay above it when your account has them on (with
[Express checkout](#express-checkout) on, the wallet buttons are at the top of the page
instead). It is mounted before any order exists
([deferred intent](/developers/08b-payment-element.md), `mode: 'payment'`) with the cart total, which
it keeps current as the cart changes.
2. The buyer clicks WooCommerce's own **Place order** (or approves Apple Pay / Google Pay). The
Element checks the card first. Then WooCommerce's checkout runs as usual: it validates the
form and creates the order, and the plugin creates a
[payment intent](/developers/08b-payment-element.md#1-create-the-intent-your-server) for the order's
total with your secret key (`reference` = the order number, and `metadata` `order_id` /
`order_number`, which the charge and its webhooks carry too).
3. The Element pays that intent. Declines are shown inside it, and the buyer can try another
card: placing the order again reuses the same order and intent. 3-D Secure challenges open
inside the Element.
4. The plugin re-reads the intent and its charge **with your secret key** and moves the order.
The browser's word is never taken for it.
The Checkout block works the same way, and so does the pay-for-order page (a failed order, a
payment link), where the order already exists.
A cart that starts a subscription is paid the same way, with two differences: the Element
shows no **Save this card** box and no Apple Pay / Google Pay buttons, because the card is
always saved (a wallet's card cannot be), and the checkout says so under the form. At a $0
total (a free trial) the Element is mounted to save the card with no payment
([setup mode](/developers/08b-payment-element.md#saving-a-card-without-a-payment-setup-intents)): the
plugin creates a setup intent instead of a payment intent, and nothing is charged.
While the Element confirms a payment (and runs 3-D Secure), the rest of the checkout form is
frozen and a busy line shows above the card form; Place order does nothing until the payment
form has loaded, and says so.
| What the API says | Order status |
|---|---|
| Captured | **Processing** (or **Completed** for virtual, downloadable items) |
| Authorized only (a manual or delayed Capture on your Payment Workflow route) | **On hold**, with Capture / Void actions |
| `processing` (outcome not known yet) | **On hold**, updated by the webhook |
| Declined | Stays **Pending payment** and payable; a `charge.failed` webhook marks it **Failed** (still payable) |
An order whose payment did not go through stays **Pending payment**. Placing the order again
reuses the same order and the same intent, so nothing is charged twice.
**When the buyer does not come back.** A buyer may close the page right after 3-D Secure, or
lose the redirect back to your store. The webhook settles the order then. In case it is
missing or late, the plugin also reads the order's intent itself, with your secret key, 10
minutes after the buyer was given it, then after 30 minutes, 2 hours and 12 hours, and stops
as soon as the order is settled or the intent is paid or expired. A $0 sign-up's setup intent
is read the same way. The looks run on WooCommerce's Action Scheduler (**WooCommerce → Status
→ Scheduled Actions**, hook `worthypayments_reconcile_intent`).
**Hold stock.** When WooCommerce's **Hold stock** time runs out (**WooCommerce → Settings →
Products → Inventory**), WooCommerce cancels the unpaid orders. The plugin reads each of its
own orders' intent first: an order whose payment succeeded or is still settling is moved to
the right status instead, and never cancelled. If the intent cannot be read (our API
unreachable), the order is kept and asked about again at the next cleanup. An intent the buyer
never paid does not keep the order. A payment that still lands on an order already cancelled
moves it to **On hold** with a note asking you to fulfil or refund it.
---
## Appearance
The card form is the WorthyPayments Payment Element, a frame on our domain, so your theme's
CSS cannot reach into it. With **Payment form look** set to `Match my theme` (the default), the
plugin reads what your checkout already shows and passes it to the form as
[appearance variables](/developers/08-embedded-sdk.md#appearance):
| Taken from your checkout | Becomes |
|---|---|
| A billing field's background and text colour | `colorBackground`, `colorText` |
| Its label colour | `colorTextSecondary` |
| Its border colour (when it has a border), corner radius, font and font size | `colorBorder`, `borderRadius`, `fontFamily`, `fontSizeBase` |
| The **Place order** button's colours | `colorPrimary`, `colorPrimaryText` |
A dark field starts the form from the `night` theme. Anything that would be hard to read is
left in the default look rather than copied:
- text that does not contrast with its background;
- a button colour too close to the field's;
- a colour outside sRGB (for example `display-p3` or `oklch()`);
- a font size outside 12–20 px.
Corners are capped at 24 px.
The form loads only the fonts it serves itself (Inter, Roboto, Open Sans, Lato, Montserrat
and Poppins). Any other font falls back down your theme's font stack.
**Accent colour** and **Corner radius** win over what is read from the theme.
`WorthyPayments default` turns the theme matching off and leaves only those two.
Developers can change the result at two points.
- **PHP, on the server.** `worthypayments_element_appearance` receives the settings as
`['variables' => […]]` and may return `theme`, `variables` and `rules`. Its result is laid
over what is read from the theme. `worthypayments_element_match_theme` (a bool) turns the
theme reading on or off.
- **JavaScript, in the browser.** `wp.hooks` filter `worthypayments.elementAppearance` gets
the final appearance and has the last word.
```php
add_filter( 'worthypayments_element_appearance', function ( $appearance ) {
$appearance['variables']['colorPrimary'] = '#0f766e';
$appearance['rules']['.Input'] = array( 'boxShadow' => 'none' );
return $appearance;
} );
```
```js
wp.hooks.addFilter( 'worthypayments.elementAppearance', 'my-theme', function ( appearance ) {
appearance.variables.fontFamily = 'Poppins, sans-serif';
return appearance;
} );
```
A name or value the form does not accept is dropped (see
[what appearance does not control](/developers/08-embedded-sdk.md#what-appearance-does-not-control)).
The card is always typed into the WorthyPayments form, so changing its look never changes your
PCI scope.
---
## Express checkout
With **Express checkout** on, the Apple Pay and Google Pay buttons themselves appear on
product pages, on the cart page above **Proceed to checkout**, at the top of the classic
checkout page, and in the express area of the Cart and Checkout blocks (worthypayments.js
[`mountExpressCheckout()`](/developers/08-embedded-sdk.md#mountexpresscheckouttarget-opts)). The buyer
pays in the wallet's own sheet, signed in or as a guest:
1. The sheet opens with the total WooCommerce computed for the cart (on a product page, for
that product alone).
2. If the order ships, the buyer picks the shipping address and the shipping method **in the
sheet**. Each choice is priced by WooCommerce, with your shipping zones, rates and taxes,
and the sheet shows the new total before the buyer authorizes. An address you do not ship
to is refused: Google Pay says so on the address; on Apple Pay the payment is refused when
the buyer authorizes, and nothing is placed.
3. Only when the buyer authorizes is the order placed, by WooCommerce's own checkout, with the
e-mail, name, phone, billing address and shipping address from the sheet (every checkout
validation and hook runs; a refusal is shown in the sheet). The plugin creates its intent,
and the wallet pays it. The order note says **Placed with express checkout (Apple Pay)**.
4. The order is then verified exactly like any other: the plugin re-reads the payment with your
secret key. A sheet closed without paying leaves no order behind.
The total the buyer authorized is the total charged: if the order's total differs from the
one the sheet showed (a coupon or fee that depends on the billing address, for example), the
payment is refused, nothing is charged, and the buttons are drawn again with the new total.
The buttons are offered when:
- **Apple Pay or Google Pay is on** for your account in this mode (checked with
`GET /v1/wallet-config`, once an hour) and the buyer's device can pay with it;
- the buyer may check out: signed in, or a guest where WooCommerce allows guest checkout,
or where an account is created at checkout with a generated password (the sheet cannot
ask for one). A subscription always needs an account;
- there is something to pay now (a $0 free trial is not offered).
With the buttons on the page, the checkout's own payment form shows no second set of wallet
buttons: card entry stays there.
**Subscriptions** are offered **Google Pay only**, which hands back the card itself: it is
saved and renewals charge it, like a card typed in. An Apple Pay token cannot be saved yet,
so Apple Pay is not offered for a subscription. On a product page, a subscription with a free
trial or a sign-up fee sends the buyer to the cart (what is charged today is not its price).
On a product page, the cart becomes that product alone once the buyer opens the sheet, as with
Stripe's express checkout. If the store requires accepting its terms, the terms sentence is
shown under the buttons, and paying through them accepts it. A cart split into several
shipping packages offers the first package's methods in the sheet; the others keep the
method already chosen.
### Apple Pay on your domain
Apple shows the Apple Pay button only on a domain registered for your account, and verifies
the domain by fetching `https:///.well-known/apple-developer-merchantid-domain-association`.
The plugin serves that file (WorthyPayments' copy, refreshed weekly). Then:
1. Save the plugin settings while Apple Pay is on: the plugin reads the file back from your
site, the way Apple does, and says whether it is served. A server rule, a redirect, or
another plugin's file at that address (another payment plugin's Apple Pay set-up, for
example) stops it.
2. Have the domain registered for your account: in test mode, add it in Dashboard →
**Settings → Checkout Settings → Digital Wallets**; in live mode, ask WorthyPayments
support.
Google Pay needs no domain step. Both wallets need the site served over HTTPS.
---
## Saved cards
A signed-in buyer sees **Save this card for future payments** under the card form in the
Element. It is never pre-ticked: the buyer's tick is the consent, and the card is saved with
that payment. A cart that starts a subscription has no box: the card is always saved (see
[WooCommerce Subscriptions](#woocommerce-subscriptions)), and the checkout tells the buyer.
State on your site what saved cards are used for (one-click checkout, subscription renewals)
and how a buyer removes one.
- **Customer.** The first time a signed-in buyer pays, the plugin finds or creates their
[customer](/developers/18-customers.md) by the account's e-mail and keeps the `cus_…` id on the
WordPress user (one per mode). The intent names that customer, so the card is saved to it.
- **The card.** The plugin reads the save result from the intent with your secret key
(`card_save`) and adds the card to the buyer's account as a WooCommerce saved card:
brand, last four and expiry, with our `pm_…` id as the token. A save that is still
`pending` is looked up again three times over the next 40 minutes. It is never guessed: a
card the customer already had before this payment is only taken if it is already this
account's saved card.
- **Paying with a saved card.** The buyer picks it in the payment list (classic checkout,
the Checkout block, the pay-for-order page). The plugin charges it from your server with
[`POST /v1/charges`](/developers/03-charges.md#charge-a-saved-card), `initiated_by: "customer"`, the
way Stripe's own WooCommerce plugin charges a saved card. The buyer is present, so your
3-D Secure rules apply, and so does your `worthypayments_three_ds_request` filter. When
one asks for 3-D Secure, the card is authenticated on the encrypted copy kept when it was
saved: the challenge opens on the checkout page, and once the buyer finishes it the plugin
sends the same charge again with the authentication, under the same idempotency key
([3DS on a saved card](/developers/07-3ds.md#3ds-on-a-saved-card)). A card with no such copy (saved
before 2026-10-18, or imported from your NMI vault) is declined `soft_decline_3ds` with
nothing charged: the buyer is asked to choose **Use a new payment method** and enter the
card again, once. The checkout page also sends the buyer's device signals from
`worthypayments.js` (`getFingerprint()`, `getKountSessionId()`) with the choice, and the
plugin passes them on as the charge's `device` for fraud scoring, as the Element does for a
new card. It sends the buyer's device session too (`getDeviceSession()`, refreshed while the
page stays open), as the charge's `device_session`, so the charge carries the buyer's own IP
address and browser rather than your server's
([Device data collection](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser)).
When the buyer places the order, the page asks for the signals once more and waits for them,
2 seconds at most (classic checkout, the pay-for-order page and the Checkout block), then
sends the order with what it has: the payment never waits longer for a signal.
A signal the browser could not produce is left out, and the payment goes ahead without it.
If our API refuses an expired session (the same charge sent again after a 3-D Secure
challenge or a re-check can outlive its 30 minutes), the plugin sends that charge once more
without it, under the same idempotency key. A renewal has no buyer at a browser, so it has
no session: our API records your server's address as your server's, and no per-IP fraud
rule counts it.
- **My account → Payment methods** lists the cards and lets the buyer set a default or delete
one. Deleting a card there deletes it at WorthyPayments too
(`DELETE /v1/customers/{id}/payment_methods/{pm}`).
- **Add payment method** (on that page) saves a new card with no payment. The Element is
mounted in [setup mode](/developers/08b-payment-element.md#saving-a-card-without-a-payment-setup-intents);
when the buyer saves, the plugin creates a setup intent for their customer
(`POST /v1/setup_intents`, `usage: off_session`), the Element verifies the card with a $0
authorization and saves it, and the plugin re-reads the setup intent with your secret key
before the card becomes a WooCommerce saved card. WooCommerce's own limit of one new card
every 20 seconds is applied before the card is verified, so a refused add never leaves a card
saved behind it.
- Cards are kept per mode: test cards are hidden while the store is in live mode.
- Wallet payments (Apple Pay, Google Pay) are not saved as cards, except a subscription paid
with Google Pay through [Express checkout](#express-checkout). Guests cannot save cards.
---
## WooCommerce Subscriptions
The plugin supports [WooCommerce Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/):
renewals, cancellation, suspension and reactivation, amount and date changes, payment-method
changes by the customer and by an admin, and several subscriptions in one order.
**The first order.** The buyer pays it in the Element, and the card is always saved: the
plugin creates the order's intent with
[`setup_future_usage: "off_session"`](/developers/08b-payment-element.md#always-saving-the-card-subscriptions)
and the order's customer, so there is no box to leave unticked, and the checkout tells the
buyer that the card is kept for the renewals. The customer is the one of the order's account,
so a buyer who creates their account at checkout is covered as well as one who was signed in.
That payment is the initial customer-initiated transaction the card is saved with; every
renewal on the card references it. The payment form offers no Apple Pay or Google Pay for a
subscription cart (a wallet's device token cannot be saved and renewed); with
[Express checkout](#express-checkout) on, the buyer may pay with **Google Pay**, which hands
back the card itself, saved and renewed like a typed card. A buyer may also pay with a saved
card.
**A $0 sign-up** (a free trial) has nothing to pay, so the card is saved with no payment: the
plugin creates a [setup intent](/developers/08b-payment-element.md#saving-a-card-without-a-payment-setup-intents)
for the order's customer (`POST /v1/setup_intents`, `usage: off_session`, your
`worthypayments_three_ds_request` filter applied), the Element verifies the card with a $0
authorization, and the plugin re-reads the setup intent with your secret key (on the return,
the Store API's second call or the `setup_intent.succeeded` webhook, whichever comes first).
Then the card becomes the buyer's saved card and the subscription's, and the order is
completed. A buyer may pick a saved card instead.
When no card is saved with the first order (no customer could be found or created for it, the
save failed), the subscription is switched to **manual renewal** with a note: WooCommerce
Subscriptions asks the customer to pay each renewal, and the first renewal they pay on the
site, or a change of the subscription's card in their account, switches it back to automatic.
**Renewals.** WooCommerce Subscriptions creates the renewal order and asks the plugin for the
payment. The plugin makes one server-side charge, with no browser:
```http
POST /v1/charges
Idempotency-Key:
```
```json
{
"amount": 2000,
"currency": "usd",
"payment_method": { "payment_method_id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461" },
"recurring": true,
"initiated_by": "merchant",
"description": "Order #5001",
"customer": { "email": "jane@example.com", "name": "Jane Doe" },
"billing_address": { "line1": "510 Townsend St", "postal_code": "94103", "country": "US" },
"metadata": { "order_id": "5001", "order_number": "5001", "subscription_id": "4990" }
}
```
Every payment the plugin makes carries the same `metadata` keys, so a charge and its webhooks
name the order the same way whichever way it was paid: `order_id` (WooCommerce's order id) and
`order_number` (what the buyer and the admin see), plus `subscription_id` on a renewal. An
intent's metadata is copied onto its charge; a saved-card payment and a renewal send it on
`POST /v1/charges`. Before 2.4.0, saved-card payments and renewals carried `wc_order_id`,
`wc_subscription_id` and `wc_site` instead, and charges made then keep those keys: read
`order_id` and fall back to `wc_order_id`.
- **Always 2D.** A renewal is merchant-initiated: it never sends `three_ds`, never runs your
`worthypayments_three_ds_request` filter, and is charged without 3-D Secure whatever your
rules ask (a customer flagged **Require 3-D Secure**, the 3DS risk band, a fraud rule asking
for 3DS). A fraud rule that blocks still blocks it.
- **One key per attempt.** The key and the exact body are stored on the renewal order before
the call. A re-run of the same attempt (a cron re-run, a timeout) sends the same request
with the same key, so it can never charge twice. When the attempt gets a final answer, the
key is spent: a later retry of a declined renewal is a new attempt with a new key, because
the old key would only replay the decline (see [Idempotency](/developers/12-idempotency.md)).
- **Retries are WooCommerce Subscriptions'.** A declined renewal is marked **Failed** with a
note naming our decline code. If **Retry Failed Payments** is on (WooCommerce → Settings →
Subscriptions; it is off by default), Subscriptions retries by its own rules; otherwise it
e-mails the customer to pay the renewal themselves. The plugin adds no retry of its own, and WorthyPayments does not retry a
renewal your store charges through the API, so there is only one retry schedule.
- **A card whose bank wants the customer to confirm the payment.** A renewal never runs 3-D
Secure, so such a card is declined `soft_decline_3ds`, with nothing charged. As Stripe's
plugin does, the customer then gets the plugin's own e-mail, **Renewal payment needs
confirmation (WorthyPayments)** (WooCommerce → Settings → Emails, where you can turn it off
or reword it): their bank needs them to confirm the payment, with a **Confirm and pay the
renewal** link to the renewal order's payment page, where they pay with the card present and
the bank can ask them to confirm it. Subscriptions' failed-renewal and retry e-mails, and
WooCommerce's failed-order e-mail, are not sent for that renewal, so the customer gets one
e-mail. Retries of the same card are declined the same way.
| What the API answers | Renewal order |
|---|---|
| Captured | **Processing** / **Completed**; the subscription continues |
| Authorized only (a Capture node on your route) | **On hold**, with Capture / Void actions |
| `402` `soft_decline_3ds` | **Failed**, note; the customer gets the **Renewal payment needs confirmation** e-mail with the payment link |
| Another `402` decline, or another refusal | **Failed**, note with the code; Subscriptions retries if its retry system is on, otherwise e-mails the customer a link to pay it |
| `pending`, a `5xx`, or no answer | **On hold**; the plugin asks again with the same key (every 10 minutes, a few times), or the webhook settles it. If it still cannot tell, a note asks you to look the payment up in your dashboard |
**Changing the card.** In My account → Subscriptions → **Change payment**, the customer picks
one of their saved cards, or **Use a new payment method**: the Element saves the new card with
no payment (a setup intent for the subscription's customer, as for **Add payment method**), the
plugin re-reads it with your secret key, and renewals charge the new card from then on. Nothing
is charged now. An admin can edit the subscription's **WorthyPayments card** (`pm_…`) and
**WorthyPayments customer** (`cus_…`) on the subscription screen; the plugin checks with
WorthyPayments that the card belongs to that customer before saving.
**A failed renewal** can be paid by the customer from their account, with a saved card or a
new one in the Element (always saved, as for the first order); renewals then continue on the
card they paid with.
**The card on show.** My account → Subscriptions, and a subscription's own page, name the card
renewals charge: *Via Visa ending in 4242* rather than the method's title. The admin's
subscription list and screen add it to the title: *Credit or debit card (Visa ending in
4242)*. The brand and last four come from the customer's saved card holding the subscription's
`pm_…`; a card that is not one of their saved cards shows the title alone.
Subscriptions that renew through another gateway (your NMI plugin, during the move) are
never touched by this plugin, except by the migration command below when you run it.
---
## Moving subscriptions from your NMI plugin
If your subscriptions renew through an NMI gateway plugin today, WorthyPayments can import the
cards in your NMI Customer Vault as saved cards on our side (no card is re-entered, and
nothing is changed or deleted at NMI). After the import, WorthyPayments support gives you a
**mapping file**: one row per imported vault entry, with the NMI `customer_vault_id`, our
`customer` (`cus_…`) and `payment_method` (`pm_…`), and the card's brand, last four, expiry
and whether its first NMI transaction was found (`anchor`). It holds no card number. JSON
lines or CSV with a header row:
```json
{"customer_vault_id":"1234567890","customer":"cus_0d7f6c2a-9d1b-4d8e-b8a3-1f2e3d4c5b6a","payment_method":"pm_5c2f1e90-3a4b-4c6d-8e0f-9a1b2c3d4e5f","card_brand":"visa","card_last4":"1111","card_exp_month":11,"card_exp_year":2030,"anchor":true}
```
Put the file on your server (never in the web root) and run, with WP-CLI, first a dry run:
```bash
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --dry-run
```
Then a first small run, then the rest. Every change is appended to the log you name:
```bash
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --log=/root/migration.jsonl --limit=10
```
| Option | Meaning |
|---|---|
| `--mapping=` | The mapping file. A file missing a column or with a malformed row is refused whole, with the line number. |
| `--dry-run` | Report only; nothing is written. |
| `--log=` | Required without `--dry-run`. JSON lines: each subscription's state before the move, then the result. `revert-subscriptions` reads it. |
| `--gateway-from=` | Your NMI plugin's gateway id. Default `nmi-for-woocommerce`. |
| `--source=token` (default) | The vault id is the token of your NMI plugin's saved card attached to the subscription (the last one attached), or the buyer's only saved card of that gateway. |
| `--source=subscription-meta` / `user-meta` with `--source-key=` | The vault id is in that meta key on the subscription / on its user. |
| `--mode=test\|live` | Which keys the cards belong to. Default: the store's active mode. |
| `--batch=` | Subscriptions read per page (default 100). |
| `--limit=` | Stop after moving (or, in a dry run, finding) this many. |
The command reads the **active** and **on-hold** subscriptions paid with `--gateway-from`, and
for each one:
1. reads its NMI vault id and looks it up in the mapping;
2. reads our customer with your secret key: its e-mail must be the buyer's account e-mail or
the subscription's billing e-mail, and the mapped card must be one of that customer's
cards;
3. outside a dry run: stores the `cus_…` on the WordPress user (when it has none), adds the
card to the buyer's saved cards, switches the subscription to WorthyPayments with the
`pm_…`, and adds a note naming the vault id's last four characters and the card. A
subscription set to manual renewal stays manual.
One line per subscription, then totals:
| Outcome | Meaning |
|---|---|
| `would_migrate` / `migrated` | Moved (or would be, in a dry run). |
| `already_ours` | Moved by an earlier run. Re-running is safe. |
| `no_vault_id` | No vault id where `--source` looks: check the source with a dry run. |
| `no_mapping` | The vault id is not in the mapping (the import could not take that card): the subscription stays on NMI until the buyer updates their card. |
| `ambiguous` | The mapping lists the vault id with two different cards. Ask support. |
| `customer_conflict` | The user already has a different WorthyPayments customer. |
| `email_mismatch` | The mapped customer's e-mail is not the buyer's. Nothing is attached to a card that may belong to someone else. |
| `card_not_customers` | The mapped card is not that customer's card. |
| `renewal_in_progress` | Subscriptions is charging its renewal right now. Run again later. |
| `error` | Our API could not be read, or WooCommerce refused a write. The note says why; the run goes on. |
The totals also say how many moved cards carry their stored-credential anchor (renewals on
those reference the buyer's original NMI transaction), and list the mapping rows no
subscription used. Run in a quiet window, or pause Action Scheduler, so that no NMI renewal
runs during the move.
**Nothing is deleted.** NMI is never called, and your NMI plugin's saved cards and meta stay on
every subscription and user. Deleting a card that was imported from your NMI vault never
deletes it at NMI. To move subscriptions back, re-activate your NMI plugin and run:
```bash
wp worthypayments revert-subscriptions --log=/root/migration.jsonl --dry-run
wp worthypayments revert-subscriptions --log=/root/migration.jsonl
```
A subscription is put back only while it still renews with the card the migration gave it (a
card change since then wins), and only when the old gateway is active. The saved card stays in
the buyer's account. Disable the NMI plugin once no active subscription uses it, and keep it
installed for one full billing cycle.
---
## 3-D Secure
The Element runs every challenge; nothing is configured in the plugin. By default our rules
decide when a payment is stepped up (see [3-D Secure](/developers/07-3ds.md#when-does-a-step-up-happen)).
To decide per order from your own code (a rules engine, a risk score), use the
`worthypayments_three_ds_request` filter. It runs when the order's intent is created, and when
the buyer pays with a saved card (never for a renewal):
```php
add_filter( 'worthypayments_three_ds_request', function ( $request, WC_Order $order ) {
if ( $order->get_total() >= 500 ) {
return 'challenge';
}
return $request; // 'automatic'
}, 10, 2 );
```
| Value | Meaning |
|---|---|
| `automatic` | Our rules decide (default). Nothing extra is sent. |
| `any` | Authenticate, no challenge preference. |
| `challenge` | Authenticate, ask the issuer for a challenge. |
| `challenge_mandated` | Authenticate, a challenge is mandated. |
The request applies to a card typed for this payment and to a saved card the buyer picks (see
[Saved cards](#saved-cards)). A rule on our side can still require a step-up the filter did
not ask for. A saved card's challenge runs on the checkout page through Evervault, the same
provider the Element uses: if your site sets a Content-Security-Policy, allow
`https://js.evervault.com` (scripts) and `https://*.evervault.com` (frames and connections),
as in [Content-Security-Policy](/developers/08-embedded-sdk.md#content-security-policy).
---
## Tax rates from WorthyPayments
By default WooCommerce computes tax from its own rates. With **Tax rates** set to **My
WorthyPayments tax rates**, cart and checkout tax comes from the tax rates you keep with us
instead: the plugin asks our tax calculation for each cart, and each taxable cart line gets
the rates the answer names for it. This works on the classic checkout, the blocks, the Store
API and Subscriptions' recurring totals.
- **Products.** The plugin sends each line's **WooCommerce product id** (the parent product's
id for a variation) as the line's product. Attach your rates to that id. A line with no
product rate gets your rates for the buyer's address (WooCommerce's taxable address), then
your default rate.
- **WooCommerce still does the arithmetic** with those percentages, so the order's tax lines,
refunds and reports are WooCommerce's. Each of our rates appears as a row in a WooCommerce
tax class named **WorthyPayments**, created and kept up to date by the plugin. Do not assign
products to that class; turning the setting off leaves WooCommerce's own taxes as they were.
- **Requirements.** WooCommerce taxes enabled (**WooCommerce → Settings → General**), and the
`tax:read` scope on the secret key. A product's tax status still applies: a product set to
"None" is not taxed.
- **Inclusive rates** must match WooCommerce's "Prices entered with tax" setting. A rate whose
`inclusive` flag differs is refused, like an unreadable answer.
- **Shipping and fees** keep WooCommerce's own rates (our calculation does not tax shipping).
- **When the rates cannot be read** (our API unreachable, a missing scope, a refused rate), the
cart shows no tax and the order cannot be placed: the buyer sees *Tax could not be calculated
for this order. Please try again in a moment.* No order is charged without its tax.
- Answers are reused for 10 minutes for the same cart and address. A renewal keeps the tax
lines Subscriptions copies from the subscription. **Recalculate** in the order editor uses
WooCommerce's own rates, not ours.
---
## Headless checkout (Store API)
A mobile app or your own dashboard can check out through WooCommerce's Store API with
`payment_method: "worthypayments"`. It takes four steps: read the cart, place the order,
confirm its intent in the Payment Element, and tell the store. Every Store API call carries the
cart's `Cart-Token` header (and, for a signed-in buyer's cookie session, the Store API `Nonce`
header), as WooCommerce documents. Nothing the client sends is believed: the store verifies it
with your secret key.
**0. Read the cart.** `GET /wc/store/v1/cart` carries the plugin's data under
`extensions.worthypayments`:
```json
"extensions": {
"worthypayments": {
"saved_cards": [
{ "token": 87, "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2029, "default": true }
],
"subscription": true
}
}
```
`saved_cards` is the signed-in buyer's cards (empty for a guest; see
[Paying with a saved card](#paying-with-a-saved-card)). `subscription: true` means the cart
starts a subscription: the card is always saved, so show no "save" choice and no Apple Pay /
Google Pay, and tell the buyer the card is kept for the renewals. At a zero total (a free trial)
the card is saved with no payment, through a setup intent.
**1. Place the order** (no `payment_data`):
```http
POST /wc/store/v1/checkout
Cart-Token: eyJ0eXAiOiJKV1Qi…
Content-Type: application/json
```
```json
{
"billing_address": {
"first_name": "Jane", "last_name": "Doe", "address_1": "510 Townsend St",
"city": "San Francisco", "state": "CA", "postcode": "94103", "country": "US",
"email": "jane@example.com", "phone": ""
},
"payment_method": "worthypayments"
}
```
The answer is `202` with the intent to confirm. For a $29 subscription:
```json
{
"order_id": 1042,
"status": "pending",
"order_key": "wc_order_1xarNfYJsEIIc",
"payment_result": {
"payment_status": "pending",
"payment_details": [
{ "key": "worthypayments_payment_intent", "value": "pi_4b124c602f49458b92204957ee5b21b6" },
{ "key": "worthypayments_intent_type", "value": "payment_intent" },
{ "key": "worthypayments_client_secret", "value": "pi_4b124c602f49458b92204957ee5b21b6_secret_…" },
{ "key": "worthypayments_status", "value": "requires_payment_method" },
{ "key": "worthypayments_return_url", "value": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1042&key=wc_order_1xarNfYJsEIIc" },
{ "key": "worthypayments_amount_text", "value": "$29.00" },
{ "key": "worthypayments_save_card", "value": "never" },
{ "key": "worthypayments_setup_future_usage", "value": "off_session" },
{ "key": "worthypayments_subscription", "value": "yes" }
],
"redirect_url": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1042&key=wc_order_1xarNfYJsEIIc"
}
}
```
For a $0 subscription sign-up the intent is a setup intent: it saves the card with no payment,
and is named under its own key, so it cannot be mistaken for a payment:
```json
"payment_details": [
{ "key": "worthypayments_setup_intent", "value": "seti_21a340a423fd45a997f221cea4e41188" },
{ "key": "worthypayments_intent_type", "value": "setup_intent" },
{ "key": "worthypayments_client_secret", "value": "seti_21a340a423fd45a997f221cea4e41188_secret_…" },
{ "key": "worthypayments_status", "value": "requires_payment_method" },
{ "key": "worthypayments_return_url", "value": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1043&key=wc_order_MsiJWTOq5SZsq" },
{ "key": "worthypayments_amount_text", "value": "$0.00" },
{ "key": "worthypayments_save_card", "value": "never" },
{ "key": "worthypayments_setup_future_usage", "value": "" },
{ "key": "worthypayments_subscription", "value": "yes" }
]
```
| Key | Meaning |
|---|---|
| `worthypayments_intent_type` | `payment_intent` (something to pay) or `setup_intent` (a $0 subscription sign-up: save the card, charge nothing) |
| `worthypayments_payment_intent` / `worthypayments_setup_intent` | The intent's id (`pi_…` / `seti_…`), the one this order owns |
| `worthypayments_client_secret` | What the Element mounts with (step 2) |
| `worthypayments_return_url` | Where the browser goes once the Element is done (step 3); also `redirect_url` |
| `worthypayments_save_card` | `auto`: mount with `fields: { savePaymentMethod: 'auto' }` to offer **Save this card** (a signed-in buyer, a purchase that starts no subscription); `never`: no box (a guest, a subscription, a setup intent) |
| `worthypayments_setup_future_usage` | `off_session`: the card is saved with this payment whatever the buyer does (the order starts or renews a subscription): tell the buyer. Empty otherwise |
| `worthypayments_subscription` | `yes` when the order starts or renews a subscription |
Placing the order again before it is paid (the same cart) answers the same order, and the same
intent while it can still take the payment (a setup intent only until a card was tried on it;
then a new one).
**2. Confirm** that client secret with the Payment Element and your publishable key. The
Element tells a payment intent's secret from a setup intent's by itself: a setup intent mounts
it in setup mode (a **Save card** button, no wallets, nothing charged). It drives 3-D Secure in
its own frame.
```js
const wp = WorthyPayments('pk_live_…');
wp.mountPaymentElement('#card', {
clientSecret, // worthypayments_client_secret
fields: { savePaymentMethod: saveCard }, // worthypayments_save_card
onSuccess: () => tellTheStore(), // step 3
onError: (e) => { /* a decline is shown inside the Element; the buyer can try another card */ },
});
```
- **Your dashboard** (a web page): mount it there. Its origin must be on the publishable key's
allowed domains ([Allow your domain](/developers/08b-payment-element.md#before-anything-renders-allowlist-your-domain)).
- **A mobile app**: host a small page that mounts the Element on an https origin listed on the
key, and open it in the app's web view (WKWebView, Android WebView). That page's origin is the
one the Element checks; a `file://` page or a custom URL scheme cannot be listed. Hand the
client secret to the page through your app's JavaScript bridge or the URL fragment, not a
query string, which servers and proxies log.
- When it is done, either call step 3 from the app, or navigate the web view to
`worthypayments_return_url`: the store reads the intent and redirects to the order-received
page (`/checkout/order-received/{order_id}/`, a URL your app can watch for to close the web
view), or, when nothing went through, back to the order's pay page (the checkout page for a $0
sign-up) with a notice.
**3. Tell the store.** Post the checkout again with the same `Cart-Token`, an unchanged cart,
and **the whole body of step 1 again** (`billing_address` and `payment_method` are required on
every call), plus `payment_data` naming the intent:
```json
{
"billing_address": { "first_name": "Jane", "last_name": "Doe", "address_1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postcode": "94103", "country": "US", "email": "jane@example.com", "phone": "" },
"payment_method": "worthypayments",
"payment_data": [ { "key": "worthypayments_payment_intent", "value": "pi_4b124c602f49458b92204957ee5b21b6" } ]
}
```
For a setup intent, send `worthypayments_setup_intent` with the `seti_…` id instead. Either
key also takes the full client secret. Without `billing_address` the Store API answers `400
rest_missing_callback_param` (*Missing parameter(s): billing_address*); without
`payment_method`, `400 woocommerce_rest_checkout_missing_payment_method`. WooCommerce reuses the
pending order only while the cart is unchanged: after a cart change the same call places a new
order, the intent you confirmed is not that order's, and the value is refused.
To finish one particular order instead (the cart changed, the app was restarted, a
pay-for-order link), post to that order:
```http
POST /wc/store/v1/checkout/1042
```
```json
{
"key": "wc_order_1xarNfYJsEIIc",
"billing_email": "jane@example.com",
"billing_address": { "first_name": "Jane", "last_name": "Doe", "address_1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postcode": "94103", "country": "US", "email": "jane@example.com", "phone": "" },
"payment_method": "worthypayments",
"payment_data": [ { "key": "worthypayments_payment_intent", "value": "pi_4b124c602f49458b92204957ee5b21b6" } ]
}
```
`key` (the `order_key` of step 1) and `billing_email` identify a guest's order; a signed-in
buyer's own order needs neither. An order that is already paid answers
`invalid_order_update_status` (*This order cannot be paid for.*).
Either way the store accepts the value only when it names **the intent of this order**, then
reads that intent with your secret key (for a payment, its amount and currency against the
order; for a setup intent, that it saved a card for the order's customer):
```json
{
"order_id": 1042,
"status": "processing",
"payment_result": {
"payment_status": "success",
"payment_details": [
{ "key": "result", "value": "success" },
{ "key": "redirect", "value": "https://shop.example.com/checkout/order-received/1042/?key=wc_order_1xarNfYJsEIIc" }
],
"redirect_url": "https://shop.example.com/checkout/order-received/1042/?key=wc_order_1xarNfYJsEIIc"
}
}
```
| Result | Answer |
|---|---|
| Paid, authorized, or still `processing`; a setup intent that saved the card | `200`, `payment_status: success`, `redirect_url` = the order-received page |
| Declined (a payment, or a setup intent's last attempt) | `400` with the buyer sentence (for example *Your card was declined for insufficient funds. Try another card.*); the order stays payable |
| Not confirmed yet, another order's intent, or an amount mismatch | `400` *The payment was not completed. Please try again.* |
If the app never comes back, the webhook still completes the order (`charge.*` for a payment,
`setup_intent.succeeded` for a $0 sign-up).
### Paying with a saved card
For a signed-in buyer, `GET /wc/store/v1/cart` lists their saved cards under
`extensions.worthypayments.saved_cards`:
```json
{ "token": 87, "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2029, "default": true }
```
Send the chosen `token` with the checkout, and the store charges the card itself, with no
Element step:
```json
"payment_data": [ { "key": "wc-worthypayments-payment-token", "value": "87" } ]
```
Add the buyer's device signals when your app can read them (`worthypayments.js`
`getFingerprint()`, `getKountSessionId()` and `getDeviceSession()`). They go to fraud scoring,
the device session gives the charge the buyer's own IP instead of your store's, and a missing
one does not stop the payment:
```json
{ "key": "worthypayments_device_fingerprint", "value": "tp_fp_9f2c…" },
{ "key": "worthypayments_kount_session_id", "value": "6a1f0c2e…" },
{ "key": "worthypayments_device_session", "value": "ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3" }
```
The answer is `200 success` (paid, authorized or still processing) or `400` with the decline
sentence. A token of another account is refused. A card with no encrypted copy for 3-D Secure
is a `400` asking the buyer to enter the card again: place the order without the token and
confirm with the Element (step 1). When your rules or filter ask for 3-D Secure, the answer is
`202` with the challenge to run:
```json
"payment_details": [
{ "key": "worthypayments_three_ds_session", "value": "tds_visa_439ab3fc28d1" },
{ "key": "worthypayments_order_id", "value": "1042" },
{ "key": "worthypayments_order_key", "value": "wc_order_…" },
{ "key": "worthypayments_three_ds_url", "value": "https://shop.example.com/?wc-ajax=worthypayments_three_ds" },
{ "key": "worthypayments_return_url", "value": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1042&key=wc_order_…" }
]
```
Run the session's challenge with Evervault in your app's web view
([Running the challenge](/developers/07-3ds.md#running-the-challenge)), then `POST` `order_id` and
`key` (form-encoded) to `worthypayments_three_ds_url`. The store sends the same charge again
with the session and answers `{ "success": true, "data": { "redirect": "" } }`
when it went through, or `{ "data": { "message": "" } }` when it did not.
Placing the order again instead starts a new attempt, with a new challenge.
---
## Webhooks
The plugin's endpoint is `https:///wp-json/worthypayments/v1/webhook`, registered
once in test mode and once in live mode.
**Set up the webhook for me** (on by default) does it when you save: for each mode whose secret
key works and whose signing secret field is empty, the plugin registers that URL with
[`POST /v1/webhook-endpoints`](/developers/16-webhooks.md) and the events below, and saves the signing
secret it gets back. If an endpoint with that URL exists already, its secret is rolled instead
(`POST /v1/webhook-endpoints/{id}/roll-secret`; the previous secret keeps working for 24
hours) and the new one saved. The secret key needs `webhooks:read` and `webhooks:write` for
this. A signing secret you filled in yourself is never replaced.
To do it by hand instead, register the URL in **Settings → Developers → Webhooks** with these
events and paste each endpoint's signing secret into the matching field: `charge.succeeded`,
`charge.authorized`, `charge.failed`, `charge.canceled`, `charge.expired`, `charge.refunded`,
`dispute.created`, `dispute.updated`, `dispute.won`, `dispute.lost`, `dispute.funds_withdrawn`,
`dispute.funds_reinstated`, `pre_dispute_alert.created`, `pre_dispute_alert.updated`,
`setup_intent.succeeded` and `setup_intent.setup_failed`. Every save checks the endpoint
registered for this site and names any of them it does not send.
The plugin verifies every delivery as described in
[Verifying a delivery](/developers/16-webhooks.md#verifying-a-delivery) (the `X-Webhook-Mode` header picks
the secret, both parts of a rolled secret are accepted, a delivery older than 5 minutes is
refused) and ignores a repeated `X-Webhook-Id`. A webhook only tells the plugin to look: the
order changes after it re-reads the payment with your secret key.
| Event | What the plugin does |
|---|---|
| `charge.succeeded`, `charge.authorized`, `charge.failed` | Re-reads the order's intent and charge (for a renewal or a saved-card payment, the charge) and moves the order |
| `charge.canceled`, `charge.expired` | An authorized order becomes **Cancelled** |
| `charge.refunded` | A refund made in your WorthyPayments dashboard is recorded on the order |
| `setup_intent.succeeded`, `setup_intent.setup_failed` | For a $0 subscription sign-up: re-reads the order's setup intent; once it saved the card, the card is recorded and the order completed (a buyer who closed the page before the return) |
| `dispute.created`, `dispute.won`, `dispute.lost` | A chargeback puts the order **On hold**, a dispute won restores its status, a dispute lost marks it **Failed** (see [Disputes](#disputes)) |
| Other `dispute.*`, `pre_dispute_alert.*` | An order note; manage the dispute in your dashboard |
Deliveries for orders the plugin did not take (another gateway's, another store's) are
answered `200` and ignored.
---
## Refunds
Refund from the order screen as usual (**Refund → Refund via WorthyPayments**), in full or in
part. Each refund calls [`POST /v1/refunds`](/developers/06-refunds.md) with an idempotency key derived
from the order, so retrying a refund that timed out does not refund twice. An authorized,
uncaptured payment cannot be refunded: void it.
---
## Capture
Whether a payment is captured at once or only authorized is set by the **Capture** node of
your Payment Workflow route (see [Authorize & capture](/developers/17-auth-capture.md)). An authorized
order is **On hold** with two order actions: **Capture charge** and **Void authorization**.
With the plugin's **Capture** setting on **Manual**, the plugin also captures when you move
the order to **Processing** or **Completed**, and voids when you cancel it. Set the route's
Capture node to manual for this to apply.
---
## Card testing
Card testing is someone trying stolen card numbers, one after another, through your checkout.
Every payment attempt passes through the plugin (a new intent, a retry after a decline, a saved
card, a $0 card check), and the plugin counts it three ways: by the order's IP address, by the
buyer's WooCommerce session and by the billing e-mail. When any of the three has made
**Payment attempts per hour** attempts (10 by default) in the last hour, the next attempt is
refused before anything reaches our API: the buyer reads *Too many payment attempts. Please
wait an hour before trying again*, and the order gets a note. A refused attempt is not
counted. `0` turns the limit off.
The counts are kept as WordPress transients, named by a salted hash, so no IP address or e-mail
is stored in plain text. Our own fraud screening (velocity and card-testing rules) still applies
to every attempt that gets through. Raise the limit if many of your buyers share one IP address
(a campus, a corporate network).
---
## Disputes
A dispute is answered in your WorthyPayments dashboard; the order shows where it stands, as
Stripe's WooCommerce plugin does:
| Delivery | Order |
|---|---|
| `dispute.created` for a chargeback on a **Processing** or **Completed** order | **On hold**, with a note: amount, reason, respond-by date. The status it had is kept on the order |
| `dispute.won` | Back to the status it had before the hold, with a note |
| `dispute.lost` | **Failed**, with a note (the disputed amount was taken back, so WooCommerce's reports stop counting the order) |
| `dispute.created` for a pre-dispute alert (Ethoca, CDRN, RDR), `pre_dispute_alert.*`, `dispute.updated`, `dispute.funds_withdrawn`, `dispute.funds_reinstated` | A note only |
| `dispute.closed` | Nothing (it repeats the `dispute.won` / `dispute.lost` it comes with) |
The order keeps each dispute's state by its `disputeId`, so one decision delivered twice moves
the order once. The order is found through the sale the dispute names, by its gateway
transaction id (`dispute.nmiTransactionId`, best-effort, see
[Dispute payloads](/developers/16-webhooks.md#dispute-payloads)); a dispute that names none yet changes
no order.
A status set by a dispute sends the customer none of WooCommerce's order e-mails (processing,
completed, on hold, failed): a dispute won is not a new "your order is being processed". The
e-mails WooCommerce sends to your store are kept.
A **Failed** order is what WooCommerce Subscriptions treats as a failed payment: a subscription
whose order is marked failed is put on hold, and Subscriptions may e-mail the customer to pay
that order.
---
## The order screen
An order paid with the plugin shows, under its general details, the card it was paid with
(brand and last four, the buyer's own card for Apple Pay / Google Pay), the mode, and a link
to the transaction in your WorthyPayments dashboard. The transaction id next to **Payment
via** links there too. A test-mode order opens in the dashboard's test mode only: switch the
dashboard to test mode first. Our API reports no fee or net amount per charge, so the order
screen shows neither.
---
## Running beside another gateway
The plugin only ever touches orders paid with it. You can keep your current gateway plugin
active while you move over: each method appears in the payment list, and each handles its own
orders, refunds and webhooks. The plugin stores the gateway's own transaction id on each order
so a sale can be matched in both systems.
---
## Troubleshooting
- **The method does not appear at checkout.** Both keys of the active mode are required, and
their prefixes must match the mode.
- **The card form is blank, or says the form could not load.** The page's hostname is not
in the publishable key's allowed domains, or your Content-Security-Policy blocks the frame.
See [Content-Security-Policy](/developers/08-embedded-sdk.md#content-security-policy).
- **A saved card asks for the card again.** It was saved before 2026-10-18 or imported from
your NMI vault, so there is no encrypted copy to authenticate it with, and your 3-D Secure
rules asked for it. Once the buyer enters it, the next 3-D Secure on that card happens in
place.
- **Webhooks answer 400.** The signing secret does not match the endpoint's mode. Empty the
field and save with **Set up the webhook for me** on: the plugin rolls the endpoint's secret
and saves the new one.
- **"Too many payment attempts."** The buyer reached **Payment attempts per hour**. It clears
an hour after their oldest counted attempt; raise the limit if your buyers share an IP
address.
- **An order stays On hold.** The outcome was `processing` and the webhook has not arrived:
check the endpoint's delivery log in your dashboard. The plugin reads the intent again
itself for 12 hours (see [When the buyer does not come back](#how-a-payment-works)). An
order on hold for a dispute stays there until the dispute is decided.
- **A subscription renews manually.** No card was saved with its first order (the note on the
subscription names why). The customer's next renewal paid on the site, or a new card under
**Change payment** in their account, switches it back.
- **Save this card does not appear.** The buyer is not signed in, the cart starts a
subscription (its card is always saved, so there is nothing to tick), or the plugin could not
create their customer (check the log; the secret key needs `customers:write`).
- **"The payment form is still loading."** Place order was pressed before the card form had
loaded; nothing was submitted. If it never loads, see the blank card form above.
- **The express Apple Pay / Google Pay buttons do not appear.** Apple Pay and Google Pay are
off for your account in this mode; the buyer's device or browser has neither (Apple Pay needs
Safari or a supported browser with a card in Wallet, Google Pay a Chromium browser); the
buyer is a guest and the store allows neither guest checkout nor accounts created with a
generated password; or there is nothing to pay now. Apple Pay alone missing: the domain is
not registered or does not serve the verification file (see
[Apple Pay on your domain](#apple-pay-on-your-domain)), or the cart starts a subscription.
- **"Tax could not be calculated for this order."** Tax rates is set to WorthyPayments and the
rates could not be read: check the log, the `tax:read` scope, and that your rates'
`inclusive` flag matches WooCommerce's "Prices entered with tax".
- **The migration reports `no_vault_id` for every subscription.** Your NMI plugin keeps the
vault id somewhere else: try `--source=subscription-meta` or `--source=user-meta` with its
meta key, in a dry run.
- **Logs.** Turn on **Debug log**, then **WooCommerce → Status → Logs**, source
`worthypayments`. Keys, client secrets, e-mails and addresses are never logged.
---
## PCI and card data
The card is entered in our iframe and encrypted by Evervault before it leaves the browser.
WordPress and your server only handle intent ids, charge ids, the client secret of the
payment, saved-card ids (`pm_…`), and the card's brand, last four digits and expiry. A saved
card is charged by its `pm_…` id; the card number stays in our vault. Your store stays on the
PCI **SAQ A** path.
========================================================================
# Errors
Every error from `https://api.worthypayments.com/v1` is returned as a single, predictable
envelope, and the **HTTP status always matches the error**. A declined charge is a `402`,
not a `2xx` with a hidden error. Branch on the HTTP status and `code`; for card declines,
also read `decline_code`.
```json
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "The charge was declined."
}
}
```
| Field | Always present | Notes |
|-------|:---:|-------|
| `type` | yes | The error category. See the table below. |
| `code` | yes | A stable machine-readable reason within the category. |
| `decline_code` | sometimes | For card declines: the issuer's specific reason when the issuer returned one, or the gateway's reason when the gateway refused the payment (see [Gateway refusals](#gateway-refusals)). |
| `message` | yes | Human-readable. Safe to show (or adapt) for the shopper. On a gateway refusal it is the gateway's own text, with its reference (`REFID:…`): log it, and show the shopper your own wording. |
> A **declined charge returns HTTP `402`** and a **failed refund returns HTTP `400`**,
> both with the envelope above. You can branch on the status code alone; you don't need to
> inspect a success body for a hidden decline.
---
## Error types
| `type` | Meaning | HTTP |
|--------|---------|:---:|
| `invalid_request_error` | Bad input, missing/invalid field, raw PAN, missing key, missing scope, unknown resource, idempotency conflict. | `400` / `401` / `403` / `404` / `409` |
| `card_error` | The card was declined by the issuer or the acquirer, or the gateway refused the payment before it reached the card network. | `402` |
| `rate_limit_error` | Too many requests. | `429` |
| `api_error` | A transient server-side problem. Internal details are never leaked. | `500` / `502` / `503` |
---
## HTTP status map
| Status | `type` | `code` | When |
|--------|--------|--------|------|
| `400` | `invalid_request_error` | `parameter_invalid` | Edge validation failed (charge amount < 50, capture/refund amount not a whole number ≥ 1, bad currency, missing field) |
| `400` | `invalid_request_error` | `parameter_unknown` | A top-level field this route does not accept. Refused, not ignored. Typical: `receipt_email` / `billing` / `order_reference` on `POST /v1/charges` (see [Charges](/developers/03-charges.md).) |
| `400` | `invalid_request_error` | `idempotency_key_required` | `Idempotency-Key` missing on a money-moving endpoint. See [Idempotency](/developers/12-idempotency.md) |
| `400` | `invalid_request_error` | `idempotency_error` | Same key reused with a **different** request body |
| `400` | `invalid_request_error` | `currency_not_supported` | Currency not enabled for this account |
| `400` | `invalid_request_error` | `test_mode_only` | Endpoint is test-mode only, called with a live key |
| `400` | `invalid_request_error` | `email_required` / `ip_required` / `invalid_action` | Required field missing on a fraud endpoint |
| `400` | `invalid_request_error` | `raw_pan_rejected` / `pan_encryption_required` | A raw card number reached the API. Send an `ev:` token |
| `401` | `invalid_request_error` | `api_key_required` | No API key on the request |
| `401` | `invalid_request_error` | `api_key_invalid` | Key unknown, expired, or revoked |
| `401` | `invalid_request_error` | `hmac_headers_missing` / `hmac_invalid` / `hmac_nonce_reuse` / `hmac_timestamp_skew` | Request signing failed. See [Authentication](/developers/02-authentication.md) |
| `403` | `invalid_request_error` | `account_not_activated` | Live key on an account that has not completed onboarding |
| `403` | `invalid_request_error` | `sandbox_key_required` | Test mode only: a call that reaches the gateway (charge, intent confirm, saved card, capture, void, refund) on an account with no NMI sandbox key of its own. Add it in the dashboard (Settings > Integrations, NMI card, in test mode); no sandbox account yet: create one at [guide.nmi.com](https://guide.nmi.com/). The refusal is not stored under the `Idempotency-Key`, so retry the same request once the key is saved. See [Testing](/developers/15-testing.md#your-own-nmi-sandbox-key) |
| `403` | `invalid_request_error` | `mode_not_allowed` | Key is fenced to a different execution mode |
| `403` | `invalid_request_error` | `domain_not_allowed` | The request's `Origin`/`Referer` is not on the key's allowlist, or a browser key arrived with no `Origin` at all. Update the list on the key without rolling the secret; see [Quickstart step 2](/developers/01-quickstart.md#2-allowlist-your-domains) |
| `402` | `card_error` | `card_declined` (+ `decline_code`) | The card was declined, or the gateway refused the payment ([Gateway refusals](#gateway-refusals)). Also a capture the gateway refused: the authorization is still held |
| `400` | `invalid_request_error` | `refund_declined` (+ `decline_code`) | The gateway refused the refund. No money moved; `message` is the gateway's reason |
| `400` | `invalid_request_error` | `void_declined` (+ `decline_code`) | The gateway refused the void. The authorization is still held |
| `403` | `invalid_request_error` | `permission_denied` | Valid key, missing scope (or `pk_` on a server-only action) |
| `404` | `invalid_request_error` | `resource_missing` | Unknown charge / refund id |
| `400` | `invalid_request_error` | `resource_missing` (`param: customer`) | `POST /v1/payment_intents` named a `customer` this key's merchant and mode do not hold (unknown, deleted, or another account's or mode's). `message` is `No such customer: ''`. The same `Idempotency-Key` replays this 400; correct the id and use a new key |
| `400` | `invalid_request_error` | `failed_precondition` | The charge is not in a state that allows this follow-up. See [Capture, void and refund errors](#capture-void-and-refund-errors) |
| `400` | `invalid_request_error` | `failed_precondition` (`FRAUD_BLOCKED`) | Our fraud screening (pre-screen velocity or the scored fraud screen) refused the charge before the issuer. A scored block also names the failed charge in `charge`. See [Fraud blocks](#fraud-blocks-fraud_blocked) |
| `400` | `invalid_request_error` | `invalid_argument` | A follow-up amount the charge cannot satisfy (see the same table) |
| `400` | `invalid_request_error` | `three_ds_amount_mismatch` | A 3-D Secure resubmit whose amount or currency differs from what the cardholder authenticated (`param: three_ds.session_id`). Nothing was charged; run a new challenge. See [3-D Secure](/developers/07-3ds.md) |
| `409` | `invalid_request_error` | `aborted` | Another capture or void is already in flight for this charge, retry after it settles |
| `409` | `invalid_request_error` | `idempotency_in_progress` | A request with the same `Idempotency-Key` is still running |
| `402` | `card_error` | `card_declined` (`decline_code` `three_ds_failed` / `three_ds_card_not_enrolled`) | The `three_ds.session_id` session did not authenticate (`param: three_ds.session_id`). Nothing was charged; the refusal is stored under the key. See [3-D Secure](/developers/07-3ds.md#a-session-that-did-not-authenticate) |
| `409` | `invalid_request_error` | `three_ds_session_incomplete` | The `three_ds.session_id` challenge has not finished. Nothing was charged and the key is not used up: resubmit with the same key once it does |
| `503` | `api_error` | `three_ds_result_unavailable` | The `three_ds.session_id` session authenticated but its result could not be read just now. Nothing was charged; retry with the same key |
| `400` | `invalid_request_error` | `parameter_invalid` (`param: three_ds.request`) | An unknown `three_ds.request`, or a request on an instrument 3DS cannot run on (saved card, gateway vault id, `wp:` envelope, wallet). See [3-D Secure](/developers/07-3ds.md#asking-for-3ds) |
| `429` | `rate_limit_error` | `rate_limit_exceeded` | Rate limit hit. See `Retry-After` |
| `500` | `api_error` | `internal_error` | Unexpected server error |
| Cloudflare `1010` | — | — | No browser `User-Agent`. **Not** this JSON envelope. An HTML interstitial. See [Cloudflare 1010](#cloudflare-1010) |
| HTML `403` (loopback in the URL) | — | — | `?origin=http://localhost…` or `?parent=http://localhost…` on the public edge. **Not** this JSON envelope. Local keys still work, send `Origin` and omit the query. See [Loopback in the URL](#loopback-in-the-url) |
---
## Common authentication & permission errors
```jsonc
// 401 — missing key
{ "error": { "type": "invalid_request_error", "code": "api_key_required",
"message": "Missing API key" } }
// 401 — invalid / expired / revoked / empty principal
{ "error": { "type": "invalid_request_error", "code": "api_key_invalid",
"message": "Invalid API key" } }
// 403 — missing scope (same shape for secret without the grant, or pk_ on a server-only route)
{ "error": { "type": "invalid_request_error", "code": "permission_denied",
"message": "API key missing scope checkout_links:create" } }
```
Branch on `code`, not on the exact `message` string, prose may be tightened without a
version bump.
See [Authentication](/developers/02-authentication.md) for the scope rules.
---
## Common request errors
```jsonc
// 400 — raw card sent instead of an ev: token
{ "error": { "type": "invalid_request_error", "code": "parameter_invalid",
"message": "Card number and CVV must be Evervault tokens (ev:...). Tokenize in-page with the browser SDK; raw card data is never accepted." } }
// 404 — unknown charge
{ "error": { "type": "invalid_request_error", "code": "resource_missing", "message": "No such charge." } }
// 400 — POST /v1/payment_intents named a customer this key's merchant and mode do not hold
{ "error": { "type": "invalid_request_error", "code": "resource_missing", "param": "customer",
"message": "No such customer: 'cus_5d87e946-1c2b-4d3e-8f40-a1b2c3d4e5f6'" } }
// 409 — concurrent retry with the same Idempotency-Key
{ "error": { "type": "invalid_request_error", "code": "idempotency_in_progress",
"message": "Idempotency key is currently processing" } }
```
### Risk decision token
These appear only when you sent a token. A charge without the field never produces them.
| Code | Cause | What to do |
|------|-------|------------|
| `risk_decision_token_invalid` | Bad signature, unknown format, or issued to a different account | Do not retry with this token. Call `/check` again. |
| `risk_decision_token_expired` | Older than 15 minutes | Call `/check` again. |
| `risk_decision_token_amount_mismatch` | Charge amount or currency differs from the evaluated one | Charge the evaluated amount, or re-evaluate with `/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 `/check`. |
| `risk_decision_token_reused` | Already spent; a token is single use | Call `/check` again. |
All of the above are `400 invalid_request_error` with `param: "risk_decision_token"`.
Two that are not:
- A token carrying a **`deny`** verdict declines the charge as `402 card_declined`
(`type: "card_error"`). You did what the API asked and the answer was no.
- **`503 service_degraded`** means token verification is temporarily unavailable. Retry
**without** the token: the charge still runs the full fraud engine, so this costs you
provenance, never the sale.
---
## Common card declines
Returned with `type: "card_error"` and HTTP `402` on a charge. `decline_code` is the
issuer's specific reason when available.
| `code` | `decline_code` | Retry? |
|--------|----------------|:---:|
| `card_declined` | `insufficient_funds` | New card |
| `card_declined` | `do_not_honor` | New card |
| `card_declined` | `security_violation` | New card |
| `card_declined` | `pickup_card` | No, do not retry |
| `card_declined` | `fraudulent` | No, do not retry |
> **In test mode, decline with the amount, not the card.** The sandbox accepts only its
> own whitelist of card numbers, and all of them approve; the `4000…` decline family
> answers `Bad Request`. An amount below 1.00 (`"amount": 99`) declines. There is no
> per-reason decline card. See [Testing](/developers/15-testing.md), "Test cards".
> The contract is a single `card_declined` `code`; the issuer's reason travels in
> `decline_code`. Earlier revisions listed `expired_card`, `incorrect_cvc` and
> `processing_error` as top-level codes. They are not the contract. Branch on
> `code` **and** `decline_code`: a 2026-08-31 audit still saw `do_not_honor` in
> `code`. `incorrect_cvc` / `expired_card` PANs are **not simulated** in the
> [Testing](/developers/15-testing.md) catalog.
```json
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "do_not_honor",
"message": "The charge was declined."
}
}
```
---
## Gateway refusals
Sometimes the payment gateway refuses the request itself, before it ever reaches the card
network, for example because an identical payment just went through. That is still a
`402` `card_error` with `code: "card_declined"`, returned at once (the charge does not sit
`pending`; `duplicate_transaction` has one exception, below), and `decline_code` is one of:
| `decline_code` | Meaning | Retry? |
|----------------|---------|:---:|
| `duplicate_transaction` | The gateway already holds an identical recent payment (same card, same amount). The buyer may already have paid. | **No.** Look the payment up first |
| `invalid_merchant` | The gateway does not accept this account's request (credentials or account setup). | No, contact support |
| `invalid_transaction` | The gateway does not allow this transaction. | No, contact support |
| `processing_error` | Any other gateway refusal. Nothing was charged. | Yes, after a short delay, with a new `Idempotency-Key` |
| `merchant_unavailable` | Payment intents and hosted checkout: the account cannot take payments right now (not activated, on hold, or its gateway setup is unusable). Nothing was charged. | No, contact support |
| `fraudulent` | Payment intents and hosted checkout: our fraud screening (the pre-screen or the scored screen) refused the payment. Nothing was charged. See [Fraud blocks](#fraud-blocks-fraud_blocked). | No |
### Payment Workflow rules
A route in your Payment Workflow can refuse a payment before it reaches the gateway: a route
that requires a CVV match, an AVS or postal-code match, a minimum or maximum amount, a currency,
or that blocks anonymous IPs (proxy / VPN). That is the same `402` `card_error`,
`code: "card_declined"`, on every surface (`POST /v1/charges`, payment intents, hosted
checkout), with one `decline_code` and a `message` the buyer can act on:
| `decline_code` | Meaning | Retry? |
|----------------|---------|:---:|
| `incorrect_cvc` | The card's security code did not match. | Yes, after the buyer corrects it |
| `incorrect_zip` | The card's postal code did not match. | Yes, after the buyer corrects it |
| `incorrect_address` | The card's billing address did not match. | Yes, after the buyer corrects it |
| `amount_too_large` / `amount_too_small` | The amount is outside the route's limits. | No, change the amount or the route |
| `currency_not_supported` | The route does not accept this currency. | No |
| `fraudulent` | The route blocks anonymous IPs and this one looked anonymized. | No |
Nothing was charged. When several rules fail, the code names one: an anonymous IP first, then
the field the buyer can fix, then a limit. Retry with a **new** `Idempotency-Key`: the same key
replays the same refusal. A payment intent stays usable, so the buyer corrects the field and pays
again on it; the Payment Element shows the matching sentence.
`message` carries the gateway's own text, including its reference:
```json
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "duplicate_transaction",
"message": "Duplicate transaction REFID:3154897712"
}
}
```
**Do not retry `duplicate_transaction`.** Check [`GET /charges`](/developers/03-charges.md) (or your
`charge.succeeded` webhook) for the payment it duplicates before you charge again. Retrying
under a new key is how a buyer is charged twice. In test mode the sandbox treats the same
card and amount as a duplicate for about 20 minutes: vary the amount between test runs.
Before it answers `duplicate_transaction`, WorthyPayments asks the gateway once whether it
already holds this very charge (a copy of your own request that reached it first). If it
does, you get that payment (`succeeded`, or `requires_capture` for a manual capture)
instead of the refusal. If the gateway cannot say yet, the charge is `pending`: retry with
the same `Idempotency-Key` (see [Idempotency](/developers/12-idempotency.md#a-pending-charge-is-not-a-final-answer)).
The same decline reads differently on the charge object: its `outcome.network_status` is
`not_sent_to_network` (an issuer decline reads `declined_by_network`). `processing_error` is
also an issuer answer, so tell the two apart by `outcome.network_status`, not by the code. See
[Charges](/developers/03-charges.md).
A gateway that cannot be reached, or does not answer, is not a refusal: that stays a
`502` `api_error` with generic wording, or a `pending` charge whose outcome is still being
established. See [Idempotency](/developers/12-idempotency.md).
---
## Fraud blocks (`FRAUD_BLOCKED`)
Our fraud screening can refuse a charge **before** the issuer sees it: first the fraud
pre-screen (velocity rules), then the scored fraud screen. On `POST /v1/charges` either one
is HTTP **400**, `type: invalid_request_error`, not a 402 card decline. Through a payment
intent (the Payment Element) or the hosted checkout it is a decline instead, with
`decline_code: "fraudulent"`, and the buyer reads only "declined".
The Connect reason is `FRAUD_BLOCKED`. The public `code` is `failed_precondition`. A
pre-screen block names its rule in `message`. A scored block's `message` is `FRAUD_BLOCKED:
decision=BLOCK provider=…` and never names a rule or reason code.
### A scored block is a recorded failed charge
When the scored fraud screen refuses a charge, the charge is recorded as a failed charge
and the 400 names it in **`charge`**:
```json
{
"error": {
"type": "invalid_request_error",
"code": "failed_precondition",
"message": "FRAUD_BLOCKED: decision=BLOCK provider=internal",
"charge": "TXN-01JABCDEFGHJKMNPQRSTVWXYZ0"
}
}
```
- **Read it back** with `GET /v1/charges/{charge}`: `status: failed`, `failure_code:
fraudulent`, `failure_message: "Your card was declined."` and `outcome` `{
"network_status": "not_sent_to_network", "type": "blocked", "seller_message": "This
payment was blocked by fraud screening.", "risk_level": "highest" }`. `GET /v1/charges`
lists it with the same values. The fraud score and decision are on `GET
/v1/charges/{id}/risk`, never on the error.
- **Webhooks.** `charge.failed` and `transaction.failed` fire once for it, with
`decline.declineOrigin: "PLATFORM"`. See [Webhooks](/developers/16-webhooks.md#fraud-blocks). They
fire for a scored block from a payment intent, the hosted checkout or a subscription
charge too; those flows still answer with their own decline and do not name the charge.
- **Retries.** The 400 is stored under your `Idempotency-Key`. A retry with the same key
returns the same body, the same `charge` included, and records nothing new. See
[Idempotency](/developers/12-idempotency.md#what-gets-replayed).
- **No 3-D Secure first.** The block is decided before any 3-D Secure step-up, so a charge
the screen blocks is refused at once, never first sent through a challenge.
- **A block can still follow a challenge.** The charge a completed challenge resumes is
screened again, so velocity, a re-score or a block added while the buyer was in the
challenge can refuse it. Through a payment intent or the hosted checkout it is the same
`fraudulent` decline as on the first submit.
**A 400 without `charge`** is otherwise identical, and nothing was charged. It is one of:
- a fraud pre-screen block (below), which never carries one;
- a rare scored block for which the payments service returned no charge id. Its record
may not have been written, or it was written and only the answer naming it was lost. In
that second case the failed charge exists, and its `charge.failed` / `transaction.failed`
webhooks fire for a `TXN-…` the 400 never named.
Either way the 400 is stored under your `Idempotency-Key` and replayed exactly for 24
hours, without `charge`.
### Pre-screen velocity
Live-verified test-mode rule: about **5 distinct emails / same PAN / 1440 minutes**.
The 6th distinct email returns:
```json
{
"error": {
"type": "invalid_request_error",
"code": "failed_precondition",
"message": "FRAUD_BLOCKED: pre-screen velocity_emails_per_card"
}
}
```
Reuse `audit+0005@example.com` (and Amex `341111111111111`) to stay under the cap. See
[Testing, velocity](/developers/15-testing.md#velocity-fraud_blocked). Do not treat this as
“banned/spam cards are always blocked” in the Payment Element. That copy is not a verified
contract.
---
## Cloudflare 1010
A request that never reaches the API (typically a **missing or empty** `User-Agent`) is
answered by Cloudflare as **1010**. That is an HTML interstitial, not `{ "error": { … }
}`. There is no `code` to branch on. Curl's default UA and a Firefox UA both pass; Chrome
is not required. An empty UA dies.
```bash
curl https://api.worthypayments.com/v1/ping \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx"
```
The examples still send `-A` so a copy-paste cannot lose the header. The browser SDK
already sends a real UA.
---
## Loopback in the URL
A second public-edge HTML 403, unrelated to User-Agent: a query string that
contains `http://localhost` (or `http://127.0.0.1`). Typical probes:
```
GET /v1/element-frameable?origin=http://localhost:3000
GET /element?parent=http://localhost:3000
```
The body is `403 Forbidden
`, `server: cloudflare`, no JSON `error`.
The API never runs. **Local development is allowed.** List `localhost` on the
publishable key and send the origin as a header, with no query:
```bash
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
```
`200 {"object":"element_frameable","allowed":true}` means that origin may frame the
element. The hosted SDK already sends `Origin` and does not put a raw loopback URL in the
iframe query. You do not have to encode anything.
A `403` JSON envelope with `code: "domain_not_allowed"` is a different
failure: the request reached the API and the origin is not on the key. Add
the hostname to **Allowed domains**. HTML 403 means the request never got
that far.
See [Embedded SDK (local development](/developers/08-embedded-sdk.md#local-development) and
[Testing) loopback in the URL](/developers/15-testing.md#loopback-in-the-url).
---
## Capture, void and refund errors
A follow-up on an existing charge (`POST /charges/:id/capture`, `/void`, or `POST
/refunds`) can fail for reasons the charge itself already settled. The `code` tells you
whether it is worth retrying; the reason travels in `message`.
| `code` | `message` | Meaning | Retry? |
|--------|-----------|---------|:------:|
| `resource_missing` | `TRANSACTION_NOT_FOUND` | No charge with that id under this key | No |
| `failed_precondition` | `INVALID_STATE_FOR_CAPTURE` | Not an uncaptured authorization | No |
| `failed_precondition` | `INVALID_STATE_FOR_VOID` | Already captured, voided or failed | No |
| `failed_precondition` | `INVALID_STATE_FOR_REFUND` | Never captured, so there is nothing to return | No |
| `failed_precondition` | `REFUND_WINDOW_EXPIRED` | Past the acquirer's refund window | No |
| `failed_precondition` | `TRANSACTION_DISPUTED` | The issuer is already pulling this money back through a chargeback. Resolve the dispute instead | No |
| `failed_precondition` | `ALREADY_FULLY_REFUNDED` | The captured total is already returned | No |
| `invalid_argument` | `REFUND_EXCEEDS_REMAINING` | Refund is larger than what is left | Fix the amount |
| `invalid_argument` | `CAPTURE_EXCEEDS_AUTHORIZED` | Capture is larger than the authorization | Fix the amount |
| `invalid_argument` | `CAPTURE_AMOUNT_INVALID` | Not a positive integer of minor units | Fix the amount |
| `invalid_argument` | `CAPTURE_CURRENCY_MISMATCH` | Currency differs from the authorization's | Fix the currency |
| `aborted` | `CAPTURE_IN_PROGRESS` | Another capture holds this charge | Yes, after it settles |
| `aborted` | `VOID_IN_PROGRESS` | Another void holds this charge | Yes, after it settles |
> **Branch on `code`, not on `message`.** The upper-case string is the internal reason and
> is stable enough to log, but it is not a versioned contract. `failed_precondition` and
> `resource_missing` mean "do not retry this request as sent"; `invalid_argument` means
> "the same call works with a corrected amount"; `aborted` is the only one worth repeating
> unchanged.
Amount errors read as `400`, not `402`: nothing was presented to a card, so there is no
decline to report.
---
## Retry guidance
- **`429` / `5xx` / network errors:** retry with **exponential backoff**, honoring
`Retry-After` on a 429. These are transient. See [Rate limits](/developers/14-rate-limits.md).
- **`processing_error`:** nothing was charged. Retry after a short delay with a **new**
`Idempotency-Key`. The same key replays this decline.
- **`duplicate_transaction`:** **do not retry.** The gateway already holds an identical
recent payment. Check [`GET /charges`](/developers/03-charges.md) before charging again.
- **`invalid_merchant` / `invalid_transaction`:** do not retry unchanged. Contact support with
the `REFID` from `message`.
- **Hard declines** (`pickup_card`, `fraudulent`): **do not retry** the same card; ask the
shopper for a different payment method.
- **Always send a stable [`Idempotency-Key`](/developers/12-idempotency.md)** on charges and refunds
(same key for the same logical payment, not a new UUID every click). A charge that
returns a network error *may* still have been processed. Retrying with the same key
replays the original result instead of charging twice. A `pending` charge is the one
answer that is not replayed: retry it with the same key to learn the outcome. If in doubt,
reconcile via [`GET /charges`](/developers/03-charges.md) or your webhook.
- **`invalid_request_error`** (`api_key_required` / `api_key_invalid` / `permission_denied` /
`parameter_invalid`): never retry blindly; fix the request, key, or scope first.
========================================================================
# Health
Verifies an API key and reports the mode it resolves to. It runs no payment engine, so
use it as a connectivity and credential check. It is what the WooCommerce plugin's
**Test Connection** button calls.
```
GET https://api.worthypayments.com/v1/ping
```
Authentication: any valid API key. **No scope required**; a valid key is enough.
---
## Request
```bash
curl https://api.worthypayments.com/v1/ping -H "x-api-key: sk_live_xxx"
```
## Response
```json
{
"object": "ping",
"mode": "live",
"key_type": "secret",
"merchant_account_id": "cmt3yfavu00013iokngc9rjtw",
"api_version": "2026-01-01",
"scopes": ["payments:charge", "payments:read"],
"unrestricted": false
}
```
| Field | Notes |
|-------|-------|
| `object` | Always `"ping"`. |
| `mode` | `"test"` or `"live"`, the mode encoded in your key's prefix. |
| `key_type` | `"publishable"`, `"secret"`, or `"restricted"`. |
| `merchant_account_id` | The account the key resolves to. |
| `api_version` | The version this request was served under. See [Versioning](/developers/20-versioning.md). |
| `scopes` | `resource:action` strings this key can actually reach. A `pk_` always reports exactly `["payments:charge"]`. |
| `unrestricted` | `true` only for a legacy secret key with no permission map, which retains full access. |
> **There is no `ok` field.** An earlier revision of this page documented one; it is not
> returned. Branch on the HTTP status instead. A `200` means the key is valid, and a
> `401` carries `api_key_invalid` or `api_key_required`. `if (response.ok)` against the
> **JSON body** silently evaluated `undefined` and never ran, which is why this is called
> out rather than quietly corrected. (`response.ok` on a `fetch` *Response* is a different
> thing and is fine.)
`scopes` and `unrestricted` are returned, and are the quickest way to explain a
`403 permission_denied`. They are derived from the same rules the scope guard enforces,
so the listing cannot disagree with the gate.
A publishable key returns the same shape with `"key_type": "publishable"`.
## Errors
If the key is missing or invalid you get a `401` with the standard envelope:
```json
{ "error": { "type": "invalid_request_error", "code": "api_key_invalid", "message": "Invalid, expired, or revoked API key." } }
```
See [Errors](/developers/10-errors.md).
---
## From the SDK
The SDK exposes the same check:
```js
const info = await wp.ping();
// → { object: 'ping', mode: 'live', key_type: 'publishable',
// merchant_account_id: 'cmt3yfavu00013iokngc9rjtw', api_version: '2026-01-01',
// scopes: ['payments:charge'], unrestricted: false }
```
Use it on startup to confirm you've wired the correct key and mode before taking a real
payment.
========================================================================
# Pagination
Most list endpoints (`GET /charges`, `GET /refunds`, `GET /products`, `GET /subscriptions`,
`GET /customers`) return results in pages, newest first. Paging is **cursor-based**: you
walk forward from the last item you saw. There is no page number and no total count: this
keeps lists fast and consistent even across very large datasets. `GET /events` is the
exception (page numbers, below).
---
## The list shape
Every list response looks the same:
```json
{
"object": "list",
"data": [ { "id": "TXN-8ac4796a…", "object": "charge", .. }, { "id": "TXN-1f0c5a92…", .. } ],
"has_more": true,
"next_cursor": "eyJvIjoxMDB9"
}
```
- `data`: the page of objects, newest first.
- `has_more`: `true` if more results exist after this page.
- `next_cursor`: the value to send as `starting_after` for the next page. **Present only
when `has_more` is `true`**. The field is omitted entirely on the last page, so read it
with that in mind rather than expecting `null`.
---
## Walking forward
Pass `limit` to size the page and `starting_after` to continue from where you stopped.
**On cursor-paginated lists, `starting_after` takes the `next_cursor` from the previous
response, not an object id.** It is an opaque token, do not build it, parse it, or assume
it contains an id. Passing the last object's `id` instead is the common mistake: the value
is not rejected, it simply does not resolve to the position you meant, so you silently get
the wrong page. `GET /events` is different: there `starting_after` is a **1-based page
number** and `next_cursor` is the next page number.
```bash
# Page 1
curl "https://api.worthypayments.com/v1/charges?limit=20" \
-H "x-api-key: sk_test_xxx"
# Page 2 — start after the cursor page 1 returned
curl "https://api.worthypayments.com/v1/charges?limit=20&starting_after=eyJvIjoxMDB9" \
-H "x-api-key: sk_test_xxx"
```
Keep going until `has_more` is `false`.
```js
async function listAllCharges() {
const all = [];
let cursor = null;
do {
const url = new URL("https://api.worthypayments.com/v1/charges");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("starting_after", cursor);
const page = await fetch(url, { headers: { "x-api-key": "sk_test_xxx" } }).then(r => r.json());
all.push(...page.data);
// Follow the server's cursor — never the last item's id.
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
return all;
}
```
---
## Parameters
| Param | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | see below | 1 to 100. Values above 100 are clamped; a non-numeric or `0` value falls back to the default rather than erroring. |
| `starting_after` | string | — | The `next_cursor` from the previous page. Opaque. |
Defaults differ by endpoint:
| Endpoint | Default `limit` | Max | `starting_after` |
|---|---|---|---|
| `GET /charges`, `/refunds`, `/subscriptions`, `/customers`, `/products` | 10 | 100 | opaque `next_cursor` |
| `GET /events` | 20 | 100 | 1-based page number |
| `GET /fraud/blocklist` | 100 | 500 | query name is `cursor`, not `starting_after` |
`GET /webhook-endpoints` and `GET /customers/:id/payment_methods` are **not paginated**.
They return every row in one response, and `has_more` is always `false`.
### Filters
`GET /charges` also accepts:
| Param | Type | Notes |
|---|---|---|
| `status` | string | Filter by transaction status (e.g. `APPROVED`, `DECLINED`, `REFUNDED`). |
### Filtering by creation time
`GET /charges` and `GET /customers` accept a `created` window, in Unix **seconds**:
```bash
curl -G "https://api.worthypayments.com/v1/charges" \
-d "created[gte]=1733600000" \
-d "created[lt]=1733686400" \
-d limit=100 \
-H "x-api-key: sk_live_xxx"
```
| Form | Meaning |
|---|---|
| `created[gte]` / `created[lte]` | inclusive bound |
| `created[gt]` / `created[lt]` | exclusive bound |
| `created=1733600000` | exact match on that second |
Send each bound once, and either `created=` or `created[...]`, not both. A repeated bound or
the two forms together is a `400` `parameter_invalid`, never a guess at which one you meant.
⚠️ **Seconds, not milliseconds.** `Date.now()` is milliseconds; passed here it would land
tens of thousands of years in the future and match nothing, so we reject it with a `400`
rather than return a convincing empty page. Use `Math.floor(Date.now() / 1000)`.
⛔ **This filters when a record was CREATED, not when it last changed.** A charge created
last week and enriched an hour ago (an ARN landing, a settlement id) does not reappear in
a `created[gte]=yesterday` window. For "what changed since T", use [`GET
/v1/events`](/developers/19-events.md), which is the change feed. `/v1/disputes` and
`/v1/pre_dispute_alerts` are the exception: they take `updated_since` and are ordered
oldest-first for exactly that job.
Unrecognized query parameters are silently dropped rather than rejected, so a made-up
filter returns an unfiltered page that looks like a valid answer. Check spelling.
`GET /refunds` accepts `charge` to list refunds for one charge.
`GET /customers/:id/transactions` lists the charges belonging to one buyer, in the same row
shape as `GET /charges`.
---
## Why no total count?
Counting every matching row on a table with tens of millions of records is slow and the
number is stale the instant you read it. Cursor paging is O(page size) regardless of how
far in you are, and never skips or double-counts rows inserted while you page. If you need
a count for a specific window, page through the list and stop when `created` falls outside
the window. The results are ordered newest-first, so you can break early.
========================================================================
# Rate limits
Requests are rate-limited per API key. The limits are sized for production traffic; you
should never hit them under normal use, but build retry-on-429 into any high-volume
integration.
---
## Limits
There is **no per-minute cap on payments**. Charges, captures, payment-intent confirms and
refunds are not counted against a payment budget: a promotion that puts many buyers on the
checkout in the same minute is never refused for volume. Card testing is stopped by fraud
screening (velocity on IP, card and email, before the issuer is asked), not by counting
your payments.
### Global volume ceiling
**Every** endpoint is subject to a per-principal volume limit across three rolling windows:
| Window | Default limit |
|---|---|
| 1 second | 200 requests |
| 1 minute | 5,000 requests |
| 1 hour | 100,000 requests |
The principal is your API key (falling back to the client IP for unauthenticated calls).
These ceilings are configurable per merchant. Contact support if your steady-state
volume needs more.
These paths are exempt from the global ceiling, so health probes and the docs surface are
never throttled:
```
/health /healthz /readyz /v1/docs /v1/openapi.json /v1/ping
```
---
## When you exceed a limit
You get a **429** with the standard envelope:
```json
{
"error": {
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded (200/1s)"
}
}
```
Every 429 carries a **`Retry-After`** header (seconds): the window that was hit (`1`,
`60`, or `3600`), or `60` for a 429 that did not set its own value. There are no `RateLimit-*` headers. The `message` names the
limit that was hit.
---
## Handling 429 well
Honour `Retry-After`. It names the window that was hit (`1`, `60` or `3600` seconds), so a
retry sooner than that will 429 again:
```js
async function withRetry(fn, attempts = 4) {
for (let i = 0; i < attempts; i++) {
const res = await fn();
if (res.status !== 429) return res;
const wait = Number(res.headers.get("Retry-After") ?? 60) * 1000;
await new Promise(r => setTimeout(r, wait));
}
throw new Error("rate limited after retries");
}
```
For `POST /charges`, pair the retry with an [`Idempotency-Key`](/developers/12-idempotency.md) so a
retried charge replays the original result instead of charging twice.
If your steady-state volume needs higher limits, contact support. The per-key ceilings
are configurable.
========================================================================
# Testing
Test mode lets you build and verify your whole integration without moving real money.
It runs the same engine as live (fraud, routing, 3-D Secure) without touching a live
acquirer.
> Test mode is selected entirely by your key. A `*_test_*` key is test mode; a `*_live_*`
> key is live. There is no separate flag.
The card numbers on this page are ones that actually behaved as described against
`https://api.worthypayments.com` and NMI's sandbox, not a copy of a generic Stripe table.
Stripe's numbers (lost/stolen, expired, incorrect CVC, 3-D Secure) are left out on
purpose: the sandbox refuses the ones we tried (see
[Test cards](#test-cards-verified-2026-09-06-against-nmis-sandbox)).
---
## Your own NMI sandbox key
Test mode runs on **your own NMI sandbox account**. Every test call that reaches the gateway
(a charge, a payment intent or setup intent confirm, saving a card, a capture, a void, a
refund) uses the sandbox security key you put in the dashboard. Without one, the call is
refused before anything reaches the gateway:
```json
{
"error": {
"type": "invalid_request_error",
"code": "sandbox_key_required",
"message": "Test mode needs your own NMI sandbox security key. Add it in the dashboard under Settings > Integrations, on your NMI card, while the dashboard is in test mode. No NMI sandbox account yet? Create one at https://guide.nmi.com/ (Create Sandbox Account), then copy its API security key."
}
}
```
The status is `403`. Set it up once:
1. Create an NMI sandbox account at [guide.nmi.com](https://guide.nmi.com/) (**Create Sandbox
Account**). NMI gives it sandbox API keys and a sandbox merchant portal.
2. Copy that account's API security key.
3. Switch the dashboard to test mode, open **Settings > Integrations**, and paste the key into
**Security key** on the NMI card. The card asks for it while it is missing.
4. Retry. The refusal is not stored under your `Idempotency-Key`, so the same request now runs.
Your test charges and saved cards then live in your own sandbox account, and you can see them
in NMI's sandbox merchant portal. While your account is not yet live, the test-mode transactions
we sync back from NMI come from that account too; without a key nothing is synced back. NMI caps a sandbox account at **25 saved cards** (Customer
Vault entries). Remove the test cards you no longer need with
`DELETE /v1/customers/{customerId}/payment_methods/{pmId}`; the card is deleted at NMI too.
> **Test cards saved before 2026-10-18** sit in a sandbox account the platform shared between
> every account, not in yours, and they can no longer be charged. Remove them (the platform
> still deletes them from the shared account), then save the test cards again under your own
> key. Nothing changes in live mode.
---
## Cloudflare User-Agent
A request with a **missing or empty** `User-Agent` is answered by Cloudflare **1010**
(an HTML interstitial, not a JSON error envelope). Curl's default UA and a Firefox
UA both pass; Chrome is not required. An empty `-A ""` dies. Examples on this page
still send `-A` so a copy-paste cannot lose the header:
```
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
```
The browser SDK already sends a real UA. See [Errors](/developers/10-errors.md#cloudflare-1010).
---
## Loopback in the URL
Local checkout with a `pk_test` / `sk_test` key is allowed. List `localhost` on
the publishable key, then let the browser send `Origin: http://localhost:3000`.
The SDK already does that.
Do **not** put `http://localhost` in a public URL query. These are HTML 403 at the edge
(not JSON, and not an API error) and they never reach the handler:
```
GET /v1/element-frameable?origin=http://localhost:3000
GET /element?parent=http://localhost:3000
```
Probe the allowlist with the header and no query:
```bash
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
```
`200 {"object":"element_frameable","allowed":true}` is the green local-dev signal. See
[Errors (loopback in the URL](/developers/10-errors.md#loopback-in-the-url) and [Embedded SDK) local
development](/developers/08-embedded-sdk.md#local-development).
---
## The 5-minute first charge (curl only)
You don't need the browser SDK to make your first test charge. The `POST /test/tokens`
endpoint mints deterministic `ev:` tokens for a test card, so you can do the whole loop
from a terminal.
Reuse `audit+0005@example.com` on the charge (see [velocity](#velocity-fraud_blocked)
below). Charge ids look like `TXN-`, not `tx_…`.
```bash
# 1. Mint test card tokens (test keys only)
curl https://api.worthypayments.com/v1/test/tokens \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"number":"4111111111111111"}'
```
```json
{
"object": "test_token",
"card": {
"number": "ev:test_token_pan_1111",
"cvv": "ev:test_token_cvc_xxx",
"last4": "1111"
}
}
```
> The body is `{ number?: string; card?: string; cvc?: string }`. `card` is a brand alias:
> `visa` / `mastercard` / `mc` / `amex` → 4111…1111 (they share the label — see
> [Test cards](#test-cards-verified-2026-09-06-against-nmis-sandbox)), `discover` →
> 6011…0009, `jcb` → 3541…2595, `diners` → 3020…9926, `maestro` → 6799…0019. A full test
> PAN still goes in `number`. A brand string in `number` (`{"number":"visa"}`) is the same
> alias. It does not mint `last4: "visa"`. An unknown `card` is `400 parameter_invalid`.
> A live key returns `400 test_mode_only`. The **response** needs no renaming: `card.cvv`
> is named the way both `POST /charges` and the Element confirm read it, so the whole
> `card` object goes straight through. **Supply your own expiry.** A test token stands in
> for the card *secret*, not the card, so it carries no `exp_month` / `exp_year`. Any
> future date works; `12` / current year + 2 is what the dashboard's own test-card list
> uses.
```bash
# 2. Charge with those tokens — the card object goes straight through, no renaming
curl https://api.worthypayments.com/v1/charges \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"amount":2000,"currency":"usd","card":{"number":"ev:test_token_pan_1111","cvv":"ev:test_token_cvc_xxx","exp_month":"12","exp_year":"30"},"customer":{"email":"audit+0005@example.com"}}'
# → { "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "succeeded", .. }
# 3. Read it back
curl "https://api.worthypayments.com/v1/charges" \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx"
```
That's a full create → charge → list round-trip, no browser required. This path exercises
the **`ev:`** rail only. For the `wp:` rail, encrypt a test PAN yourself (see
[Card tokenization](/developers/28-card-tokenization.md)).
The same tokens vault. `POST /v1/customers/{id}/payment_methods` with
`payment_method.token` set to `card.number` from the mint is the curl path for a
saved card (subscriptions need one). See [Customers](/developers/18-customers.md).
---
## `POST /test/tokens`
Mints `ev:` tokens for a known test card so you can call `POST /charges` from a backend
or a curl script.
- **Test mode only.** A live key returns `400 test_mode_only`.
- No scope needed. Any valid test key works.
- Rate limited to 5/minute/key (50/minute on test keys).
**Request body** (all optional):
| Field | Type | Default | Notes |
|---|---|---|---|
| `number` | string | `4111111111111111` | Full test PAN, or a brand alias. Last 4 digits become the token label. |
| `card` | string | — | Brand alias: `visa`, `mastercard`, `mc`, `amex`, `discover`, `jcb`, `diners`, `maestro`. Unknown value is `400 parameter_invalid`. |
| `cvc` | string | — | Accepted on the body; the response always returns a fixed `ev:test_token_cvc_xxx`. |
**Response:**
```json
{
"object": "test_token",
"card": {
"number": "ev:test_token_pan_1111",
"cvv": "ev:test_token_cvc_xxx",
"last4": "1111"
}
}
```
`card` is the brand alias (`visa` / `mastercard` / `mc` / `amex` / `discover` / `jcb` /
`diners` / `maestro`). ⚠️ The first four all mint `ev:test_token_pan_1111` and charge as
Visa — NMI's sandbox Visa, Mastercard and Amex end in the same four digits. A full PAN
still goes in `number`. The **response** field is `card.cvv`. The same name `POST /charges` and the
Element confirm read, so there is nothing to rename.
---
## Advance a test subscription
There is no Test Clock. To run the **same renewal path** the calendar worker uses,
without waiting for `current_period_end`:
```
POST /v1/test/subscriptions/{id}/bill
```
- **TEST keys only.** A live key is `400 test_mode_only` before billing is touched.
- Scope **`subscriptions:write`**. A publishable key is refused.
- [`Idempotency-Key`](/developers/12-idempotency.md) is required. Key it on *this* advance, not the
original create. A second bill with the same key is a replay, not a second period.
`CHARGE_AUTOMATICALLY` (the default) charges the saved card, advances
`current_period_start` / `current_period_end`, **and** writes a **PAID** invoice
listed on `GET /v1/invoices`. `SEND_INVOICE` finalizes and emails an OPEN invoice.
A decline is `subscription.past_due`, not `invoice.payment_failed`. See
[Invoices](/developers/18c-invoices.md).
Reuse `audit+0005@example.com` so velocity does not block the renewal charge.
```bash
curl -X POST https://api.worthypayments.com/v1/test/subscriptions/sub_8c1e.../bill \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: bill_sub_8c1e_period_2" \
-H "API-Version: 2026-01-01"
```
A successful body is the subscription after the renewal (`status: "active"`, period
dates jumped). The opening create already took the first period; this call is the
second. Walkthrough: [Subscriptions](/developers/18b-subscriptions.md#advance-a-test-subscription).
---
## Test cards (verified 2026-09-06 against NMI's sandbox)
Type the number into the Payment Element's card field, or mint it via `/v1/test/tokens`
and charge from curl. Any future expiry works — NMI documents `10/29`. CVV `123`
(Amex `1234`).
A test-mode `client_secret` puts the element in test mode. The charge then runs the full
engine and is posted to **NMI's own sandbox**: a real gateway round-trip, not a lookup
table inside the platform.
> ⚠️ **The sandbox keeps a whitelist, and Stripe's test numbers are not on it.**
> `4242424242424242`, `5555555555554444`, `378282246310005` and the `4000…` decline family
> all answer
> `Bad Request. Please use a valid test card or test ACH account from the documentation.`
> `6011111111111117` answers `Invalid Credit Card Number`. Use the seven below, or NMI's
> 3-D Secure test numbers listed under
> [3-D Secure in the sandbox](#3-d-secure-in-the-sandbox). Both sets approve.
| Number | Network | Digits |
|---|---|---|
| `4111111111111111` | Visa | 16 |
| `5431111111111111` | Mastercard | 16 |
| `6011000991300009` | Discover | 16 |
| `341111111111111` | American Express | 15 |
| `30205252489926` | Diner's Club | 14 |
| `3541963594572595` | JCB | 16 |
| `6799990100000000019` | Maestro | 19 |
All seven **approve**. There is no per-scenario decline card here: the outcome is steered
by the request, not by the number you pick.
| To get | Send |
|---|---|
| A decline | An amount **below 1.00** — `"amount": 99`, since amounts are minor units. |
| A fatal error | An invalid card number. |
| `avs_result: MATCH` | Billing `address1` `888` **and** postal code `77777`. |
| `cvv_result: MATCH` | CVV `999`. |
> ⚠️ **Everything else reads `NO_MATCH`.** An approved sale with an ordinary address and an
> ordinary CVV comes back `avs_result: NO_MATCH, cvv_result: NO_MATCH`. That is the sandbox
> answering honestly, not a verification failure. Do not write an assertion that treats
> `NO_MATCH` on an approved charge as a bug.
> ⚠️ **Duplicate window — change the amount for each repeat on the same card.** NMI
> refuses the same card at the same amount as `Duplicate transaction` for 20 minutes, its
> default window. The platform sends no shorter window to the sandbox, because the sandbox
> refuses a per-charge override. A different order reference does not help: the check
> looks at card and amount only. Through the Payment Element the refusal arrives on
> `onError` as `card_declined` with `declineCode: 'duplicate_transaction'`, and the buyer
> reads "This looks like a duplicate of a payment you just made." Bump the amount by a cent,
> or switch brands.
`incorrect_cvc`, `expired_card` and lost/stolen are **not** separately simulated. Neither
is a specific issuer `decline_code` — the amount trigger is what produces a decline.
### What `POST /test/tokens` can reach
The mint labels a token by the card's **last four digits**, and three of the sandbox
numbers share theirs.
| Alias | Label it mints | Charges as |
|---|---|---|
| `visa`, `mastercard`, `mc`, `amex` | `ev:test_token_pan_1111` | **Visa** `4111111111111111` |
| `discover` | `ev:test_token_pan_0009` | Discover |
| `jcb` | `ev:test_token_pan_2595` | JCB |
| `diners` | `ev:test_token_pan_9926` | Diner's Club |
| `maestro` | `ev:test_token_pan_0019` | Maestro |
> ⚠️ **The `1111` collision is real and it is not going away.** Visa, Mastercard and Amex
> all end in `1111`, so one label serves all three and the charge runs as Visa. To exercise
> Mastercard or Amex specifically, type the real number into the Payment Element. A full
> PAN in `number` still works for any of the seven.
### 3-D Secure in the sandbox
3-D Secure runs in test mode. It runs on WorthyPayments' 3DS provider (Evervault), not on
NMI's Payer Authentication, so NMI's 3DS test values (a frictionless or challenge flow chosen by
the card number, and the code `12345`) do not apply here.
No card number triggers 3DS by itself. The server decides whether a charge is stepped up
(see [When does a step-up happen?](/developers/07-3ds.md#when-does-a-step-up-happen)):
- the charge's risk score falls in the default 3DS band, 70 to 95;
- the customer is marked **Require 3DS**; or
- a Payment Workflow route with **Attempt 3DS** on matches the charge.
**To force a challenge**, mark your test customer **Require 3DS** with the dashboard in
test mode: open one of that customer's transactions and click **Require 3DS** (next to
**Block customer**), or select the customer in the Customers list, click **Require 3D
Secure** and confirm. Every later charge carrying that customer's email is stepped up (a
different or missing email is a different customer), except a merchant-initiated one (a
renewal, a recovery retry, your server charging a saved card), which is charged without
3DS. The mark
applies only to the mode the dashboard is in, and it needs an admin user
(`settings:write`). To stop it, open the customer and click **Turn off** under
**Restrictions**.
Once a charge is stepped up, the first confirm returns `requires_action`. The Payment
Element (or the SDK) opens the challenge inside the Element's frame, then confirms again
with `three_ds.session_id`. Every step-up a rule triggers asks the issuer for a challenge
(and so does `three_ds.request: challenge` or `challenge_mandated`), so in test mode the
test bank screen appears for any card the sandbox accepts. With `three_ds.request: any` the
issuer may authenticate without a screen. The screen states its rule:
- `111-111` passes, and the charge is resubmitted.
- Any other code fails. The charge is not resubmitted, and the Element reports the failure
on `onError`.
NMI's own 3-D Secure test numbers are accepted too, and they approve like any other
sandbox card (approved 2026-09-29). They do not trigger 3DS, and NMI's frictionless /
challenge split for them does not apply here:
| Visa | Mastercard |
|---|---|
| `4100000000000100` | `5100000000000107` |
| `4100000000005000` | `5100000000005007` |
A passed challenge does not get past the duplicate window: after `111-111`, the same card
at the same amount inside 20 minutes is still refused as `Duplicate transaction`. Change
the amount for each repeat.
**Reading the dashboard.** In the transactions list, the 3DS column shows ✓
(Authenticated) only on an attempt that completed 3DS with a liability shift. `!`
(Attempted) means 3DS data is present without a liability shift. A dash means 3DS did not
run on that attempt; it does not mean the payment was non-compliant. The mark is recorded
before the gateway answers, so a ✓ can sit on a declined row.
On a **402**, the contract is `code=card_declined` with the issuer reason in
`decline_code`. Branch on both: some issuer reasons still arrive in `code`.
Test both branches, because they arrive on different callbacks:
- An approved charge lands on `onSuccess` with `status: 'succeeded'`.
- A decline lands on `onError` with `code: 'card_declined'`. That covers an amount under
1.00 and also a refusal from the gateway itself, such as `Duplicate transaction` or a
card number the sandbox does not accept. The element stays mounted and the intent stays
alive, so the buyer can try another card.
A decline never throws and never reaches `onSuccess`. If your decline handling lives in a
`catch` block or behind a status check on `onSuccess`, it will not run.
Creating the intent for the element still needs a secret key with
`payment_intents:create` (server-only). The publishable key only confirms.
---
## Velocity `FRAUD_BLOCKED`
> **Red box, test-mode velocity is live, and it is not optional.** About **5 distinct
> emails on the same PAN inside 1440 minutes** trip a pre-screen rule. The **6th**
> distinct email on that card is refused with **HTTP 400** (not 402):
> ```json
> {
> "error": {
> "type": "invalid_request_error",
> "code": "failed_precondition",
> "message": "FRAUD_BLOCKED: pre-screen velocity_emails_per_card"
> }
> }
> ```
>
> Unique emails **trigger** the counter. Reuse
> **`audit+0005@example.com`** with Amex **`341111111111111`** (or the same email on
> `4111111111111111`) and the charge usually succeeds.
>
> This is a fraud block, not a card decline. Do not retry with a new email on the same
> PAN inside the window. A new `Idempotency-Key` does not bypass it.
>
> The Payment Element does **not** document “banned / spam cards are always blocked”.
> That copy was not live-verified. Velocity is a charge-path pre-screen; treat
> `FRAUD_BLOCKED` as the documented block, and do not tell shoppers a card is banned
> unless you have a blocklist hit of your own.
See [Errors](/developers/10-errors.md) for the envelope next to other 400s.
---
## Test vs live checklist
Before you flip to live keys:
- [ ] A successful payment reaches `onSuccess` and you store `paymentIntentId`.
- [ ] A decline (any sandbox card charged **under 1.00**, e.g. `"amount": 99`) is handled
on `onError`, not in a `catch` block, and the buyer can retry without you
re-creating the intent.
- [ ] `status: 'processing'` does not fulfil the order. Fulfilment keys off the
`charge.succeeded` webhook, so a buyer who closes the tab still gets their goods.
- [ ] A stepped-up charge completes: the first confirm returns `requires_action`, the
challenge opens, and the resubmit with `three_ds.session_id` lands on `onSuccess`.
Mark the test customer **Require 3DS** to force it, and enter `111-111` (see
[3-D Secure in the sandbox](#3-d-secure-in-the-sandbox) and
[3-D Secure](/developers/07-3ds.md)).
- [ ] Refunds work end-to-end (`POST /refunds`, then `GET /refunds/:id`). Refund ids
look like `re_TXN-`; the parent charge is `TXN-`.
- [ ] You send an [`Idempotency-Key`](/developers/12-idempotency.md) on every charge and refund.
- [ ] You handle [429s](/developers/14-rate-limits.md) with `Retry-After` backoff.
- [ ] Your webhook endpoint verifies events and is reachable (see [Webhooks](/developers/16-webhooks.md)).
- [ ] Curl examples send a non-empty User-Agent (an empty UA is Cloudflare **1010**).
- [ ] `POST /v1/test/subscriptions/{id}/bill` advances a TEST subscription without
waiting for the calendar. A LIVE key is `test_mode_only`.
- [ ] Repeated test charges reuse one email (or you accept a `FRAUD_BLOCKED` 400).
When all of these pass in test, swap `sk_test_`/`pk_test_` for `sk_live_`/`pk_live_`.
---
## What actually differs between test and live
**Your code does not change.** Same endpoints, same request and response shapes, same
error envelope, same webhook payloads, same fraud engine. 3-D Secure is the same path as
live, so a test run that completes a challenge proves your step-up handling. Everything
on the checklist carries over.
Four things behave differently, and they are worth knowing before you flip the key:
**1. Test-mode charges go to NMI's sandbox, not to your live acquirer.** A test charge that
clears fraud is posted to NMI's sandbox gateway, a real round-trip, and an approved one
carries NMI's transaction id. The sandbox answers from its own test rules, though: it approves its test
numbers, declines on the amount, and matches AVS and CVV only on fixed test values. So
"test worked" proves your integration and the gateway request, but not the hop to your
live acquirer.
**2. A raw card number is accepted in test, and refused in live.** Test mode tolerates a
plain sandbox PAN so you can get moving from a terminal. In live, only a tokenized
instrument (`ev:`, `wp:v1:`, a saved payment method or a gateway vault id) is accepted.
The guard is derived from the key's mode, so no configuration can switch it off. If your
integration ever posts a raw number and "works", it works **only** in test.
**3. ACH is rejected in test.** Bank-rail charges are live-only, and a test-mode ACH
attempt comes back as an invalid request rather than a simulated success. Test the card
rail in test mode and validate ACH on a live key with a small real amount.
**4. Declines are chosen by you in test, by the issuer in live.** Test declines come from
the amount you send (under 1.00, see the table above), not from the card number. In live,
any card can decline for reasons
no test card reproduces, insufficient funds on a card that worked yesterday, an issuer
fraud rule, a velocity limit. Make sure your decline handling branches on `error.code`
(`card_declined`) and `decline_code` rather than on the messages you happened to see in
test.
You will also meet the [acquirer's own duplicate window](/developers/12-idempotency.md#a-second-guard-you-do-not-control-the-acquirers-own-duplicate-window)
sooner in live than in test, because real orders repeat amounts.
**Before your first real charge**, put one small live payment through the same code path a
customer will use, then refund it. That is the only way to exercise your live acquirer;
test mode stops at NMI's sandbox.
========================================================================
# Authorize & capture
By default a charge captures the funds immediately. With **manual capture** you
*authorize* the card first (placing a hold) and *capture* later. This is what you want
when you charge on shipment, confirm stock, or finalize an amount after the order.
---
## Payment Workflow Capture Timing (merchant panel)
Merchants can set **Capture Timing** on a Payment Workflow route (canvas Capture
node): `instant` | `manual` | `delayed` (+ hours).
| Canvas mode | Behaviour |
|---|---|
| **no Capture block** (`null`) | Request wins. `capture_method: "manual"` still authorizes |
| **instant** (explicit Capture node) | Request wins. A normal charge is captured at once; `capture_method: "manual"` still authorizes |
| **manual** | Authorize only. Capture later via API / panel Capture |
| **delayed** | Authorize only, then the platform auto-captures after N hours (1 to 168), or 6 hours before the card's authorization would expire if that comes first |
**A route can hold money, never capture it early:** a **manual** or **delayed**
Capture Timing on the matching route turns a charge into an authorization, whatever
the request asked. **instant**, or no Capture block, leaves the request's
`capture_method` / `CaptureMode` as sent, so an authorize-only request is never
captured by a route.
Internal design note (engineers):
`docs/superpowers/specs/2026-07-30-capture-timing-design.md`.
---
## Authorize only
Send `capture_method: "manual"` on the charge. The card is authorized but not captured
(unless a Payment Workflow route overrides it, as above):
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": 5000, "currency": "usd",
"capture_method": "manual",
"card": { "number": "ev:...", "cvv": "ev:...", "exp_month": "12", "exp_year": "30" }
}'
```
```json
{ "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "requires_capture", "amount": 5000, .. }
```
The charge sits in `requires_capture`. An authorize emits a **`charge.authorized`** webhook
(not `charge.succeeded`, since nothing is captured yet).
---
## Capture
Capture the held funds in full, or a smaller amount (partial capture):
```bash
# Full capture — Idempotency-Key is required (same as charge create)
curl -X POST https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3/capture \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: capture_ord_1234"
# Partial capture ($30 of a $50 authorization)
curl -X POST https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3/capture \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: capture_ord_1234_partial" \
-d '{ "amount": 3000 }'
```
```json
{
"id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"object": "charge",
"status": "succeeded",
"captured": true,
"amount": 5000,
"amount_captured": 3000,
"auth_code": "TESTOK",
"processor_transaction_id": "10234567890"
}
```
⛔ **`amount` is still 5000.** It is what the charge was created for and it never changes.
A partial capture is reported by `amount_captured`, not by rewriting the authorization.
The capture response is the charge as it now stands, identical to what `GET
/v1/charges/{id}` returns for it, so storing either one gives you the same figures.
A capture emits **`charge.succeeded`**. Omitting `Idempotency-Key` is rejected with
`400 idempotency_key_required`.
**Capturing more than the remaining authorization is rejected before it reaches the
gateway.** The payments service compares `amount` to what is still authorized and returns
`400` with `code: "invalid_argument"` and message `CAPTURE_EXCEEDS_AUTHORIZED`. That is
not a `parameter_invalid` naming the field, and it is not a generic processor error, do
not retry the same amount; send a smaller one or capture the remainder by omitting
`amount`.
A successful capture (full **or** partial) marks the charge captured. A second `POST
/charges/:id/capture` is then rejected with `400` `failed_precondition`
(`INVALID_STATE_FOR_CAPTURE`). The remainder is not left sitting for another capture; send
the amount you actually intend to settle, once.
⛔ **`amount` means something different on the capture response than on the charge.** The
capture reply above echoes the captured figure (`3000`); retrieving that same charge
afterwards returns `"amount": 5000`. The original authorization, which never changes.
Reconcile against `amount_captured`, which retrieve returns alongside it, and not against
`amount`. Refunds are then capped at the **captured** amount, not the authorized one: on
this charge a refund of more than `3000` is rejected with `REFUND_EXCEEDS_REMAINING`.
---
## Void (cancel)
To release an authorization without charging, or to cancel a captured charge before its
batch settles, void it. **`Idempotency-Key` is required.**
```bash
curl -X POST https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3/void \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: void_ord_1234"
```
```json
{ "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "canceled" }
```
The response is the charge as it now stands, the same object `GET /v1/charges/{id}` returns.
A void emits **`charge.canceled`**. A captured charge whose batch has not settled yet is
voided too: the charge disappears from the buyer's statement instead of settling and
coming back as a refund. If the charge has already settled (or the gateway refuses the
void because its batch closed), the void is automatically converted to a **full refund**.
The call still succeeds and answers the charge with its `amount_refunded`, but what
happened is a refund, not a cancellation. Two
consequences worth designing for:
- The event you receive is the refund event, not `charge.canceled`. Code that waits for
`charge.canceled` after calling void will wait forever on a charge that had already
settled.
- A refund is money that left and came back: it settles on the buyer's statement as a
separate line and takes days, where a void stops the charge from ever posting (the buyer
may still see the pending authorization until their bank drops it). Whether you get a void
or a refund depends on how far the charge had settled by the time you called, which is a
race against the acquirer, not something you control.
Voiding a charge that was already voided is **not** a no-op: it is rejected with `400`
`failed_precondition` (`INVALID_STATE_FOR_VOID`). There is no second gateway call and no
second event, but the call does not return 200.
A concurrent second void, or a retry while the first is still claimed, is `409`
`aborted` / `VOID_IN_PROGRESS`. Wait for the first to finish; do not fire another.
**When the gateway refuses.** A capture the gateway refuses is the decline envelope, `402`
`card_declined` with its `decline_code`; a refused void is `400` `void_declined` with its
`decline_code`. In both cases the authorization is still held and `message` carries the
gateway's reason. The refusal is the gateway's answer, so a retry with the same
`Idempotency-Key` replays it. A gateway error, where the outcome is not known, is `502`;
retry with the same key.
TEST-mode charges whose processor id starts with `TEST-` complete in our orchestration
and never call an acquirer.
---
## Lifecycle
```mermaid
flowchart LR
A["Authorize
capture_method: manual,
or a manual / delayed workflow"] --> R([requires_capture])
R -->|"Capture (API or panel)"| S1([succeeded])
R -->|"Delayed auto-capture (cron)"| S2([succeeded])
R -->|Void| C([canceled])
R -->|"Not captured in time"| X([canceled: expired])
B["Sale
default, or an instant workflow"] --> S3([succeeded])
S1 -.-> E1[["charge.succeeded"]]
S2 -.-> E1
C -.-> E2[["charge.canceled"]]
X -.-> E2
X -.-> E3[["charge.expired"]]
S3 -.-> E1
```
Authorizations expire if not captured. We cancel an uncaptured online authorization at the end
of these windows:
| Card | Customer-initiated | Merchant-initiated (a saved card, e.g. a renewal) |
|---|---|---|
| Visa | 7 days | 4 days 18 hours |
| Mastercard, American Express, Discover | 7 days | 7 days |
They are never longer than the card networks' own limits (Visa 10 days customer-initiated and
5 days merchant-initiated, Discover 10 days, Mastercard and American Express 7 days), so a
capture inside them is still within the authorization's validity.
A delayed auto-capture runs at the delay you set, or 6 hours before the authorization
expires if that comes first, so a 168-hour delay on a Visa renewal is captured after
about 4.5 days. If a capture attempt does not go through, the platform tries again after
1 minute, then waits twice as long each time, up to an hour between tries, until the
authorization expires.
Every authorized charge tells you its deadline: **`capture_before`** on the charge (and
"Capture before" in the dashboard). An authorization nobody captured by then, manual or
delayed, is **canceled when its window ends**: the charge reads `status: canceled` and can no longer be captured. The authorization is
not voided at the gateway, so the hold stays until the buyer's bank drops it, which can take a
few more days; void the charge before `capture_before` to release it sooner. You receive **`charge.expired`** and, at the same moment,
**`charge.canceled`** (or `transaction.expired` and `transaction.voided`). The check runs
every 15 minutes, so the cancellation lands within a quarter of an hour of the window
closing. If you still need the money, take a new payment. This applies to charges made
through the platform; an authorization synced in from your gateway account stays as the
gateway reports it.
Both capture and void accept an [`Idempotency-Key`](/developers/12-idempotency.md).
========================================================================
# Customers & saved cards
Create customers and save their cards so you can charge a returning customer
server-side without re-collecting card details.
```
POST /v1/customers
GET /v1/customers GET /v1/customers/:id POST /v1/customers/:id (update)
POST /v1/customers/:id/payment_methods
GET /v1/customers/:id/payment_methods
DELETE /v1/customers/payment_methods/:pmId
GET /v1/customers/:id/transactions
GET /v1/customers/:id/state
GET /v1/customers/external/:externalId/state
```
Scope: `customers:read` (reads), `customers:write` (writes, server-only, `pk_` blocked).
`GET /v1/customers/:id/transactions` requires `payments:read`. It returns charges, not
customer data.
**Listing one buyer's charges.** `GET /v1/charges` takes no `customer` filter, so use
`GET /v1/customers/:id/transactions`.
- **Same shape.** It returns the **same row shape** as `GET /v1/charges`, so one parser serves
both.
- **Charges only.** You get sales and authorizations, including those synced from your gateway.
Refund, void and capture rows never appear.
- **Paging.** It is cursor-paginated with `limit` (1–100) and `starting_after`. Pass the previous
page's `next_cursor` as `starting_after`.
- **Per-row state.** Each row says what it has already given back:
- `amount_refunded` counts refunds made through the API and in your gateway's portal alike;
- `refunded` is `true` once that is the whole amount;
- `disputed` is `true` while a chargeback or matched alert is open on it;
- `card` names the card it was paid with (`brand`, `bin`, `last4`).
That is exactly what you need to refund a customer's past charges, for example instead of
sending a payout. One customer can have paid with several cards, and a refund goes back to the
card of the charge it refunds, so refund only charges on the card(s) of the person you owe. See
[Refund a customer's past charges](/developers/06b-refund-past-charges.md).
- **Unknown customer.** An unknown customer id returns an empty list.
`GET /v1/customers` accepts a `created` window in Unix seconds. See
[Pagination](/developers/13-pagination.md#filtering-by-creation-time).
---
## Customer state: one call instead of a state machine
```bash
curl "https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/state" \
-H "x-api-key: sk_live_xxx"
```
```json
{
"object": "customer_state",
"id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "external_id": null,
"email": "ada@example.com", "name": "Ada Lovelace",
"active_subscriptions": [ { "id": "sub_...", "status": "active", "plan": "plan_gold" } ],
"payment_methods": [ { "id": "pm_...", "card": { "brand": "visa", "last4": "4242" } } ]
}
```
**This is the whole entitlement layer.** Cache it against your own user id and refresh it
when a customer or subscription webhook arrives. There is no subscription state machine to
maintain. Address it by our id via `/customers/{id}/state`, and keep the `cus_…` id
`POST /v1/customers` returned next to your own user id.
`active_subscriptions` is filtered for you, and two of those decisions are worth stating
because they are the ones every hand-written filter gets wrong:
| Status | In the list? | Why |
|---|---|---|
| `active`, `trialing` | ✅ | |
| `past_due` | ✅ | Dunning is running. Cutting access at the first failed payment punishes a customer for an expired card, and the retry that succeeds arrives after they have churned. |
| `canceled` | ✅ **until the period ends** | "Cancelled" means "will not renew". They paid for the current period. Once it ends the subscription leaves the list. |
| `paused`, `pending_payment`, `suspended` | ❌ | Billing is suspended, has not started, or dunning gave up. |
⚠️ **It fails closed.** If we cannot reach billing you get an error, never a customer with
an empty `active_subscriptions`. An empty list means "entitled to nothing", and returning
that during an outage would make every integrator caching this revoke access for their
whole customer base.
### `external_id` cannot be set yet
`GET /v1/customers/external/{id}/state` looks a customer up by `external_id`, your own id for
them. No API call sets that field today: `POST /v1/customers` reads only `email`, `name`,
`phone` and `idempotency_key`, and `POST /v1/customers/:id` does not take it either, so a
customer you create carries `"external_id": null`. The by-external-id route therefore
answers `404 resource_missing` (`param: "external_id"`) for any id you choose. Use
`/customers/{id}/state` with our id.
---
## Create a customer
```bash
curl https://api.worthypayments.com/v1/customers \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "email": "jane@example.com", "name": "Jane Doe", "phone": "+15555550123" }'
```
```json
{ "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "object": "customer",
"email": "jane@example.com", "name": "Jane Doe", "phone": "+15555550123",
"created": 1733600000, "livemode": false, "metadata": {},
"address": null, "invoice_settings": { "default_payment_method": null } }
```
`name` is split into first/last server-side. Customers are unique per email (per merchant
and mode): an email that already has a customer returns `409 invalid_request_error` with
`code: "already_exists"`, and `GET /v1/customers?email=` (below) finds that customer. A
retry with the same `idempotency_key` (in the body; this route reads no `Idempotency-Key`
header) and the same email returns the customer the first call created instead. Every
customer object carries `created` (unix seconds),
`livemode`, `address` (null until set), and `invoice_settings.default_payment_method`
(null until a card is attached as default). Those keys are always present.
---
## Update a customer
`POST /v1/customers/:id` is Stripe's update (not PATCH). `Idempotency-Key` is
**optional**, omit it, or send an empty string, and the name still updates. A present
header is honoured.
```bash
curl https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "name": "Ada Byron", "address": { "line1": "1 Ada St", "city": "London", "country": "GB" } }'
```
---
## Find a customer by email
`GET /v1/customers?email=` is an **exact** match (case-insensitive, plus-addresses are
distinct). An unknown email is an empty list, not a 404:
```bash
curl "https://api.worthypayments.com/v1/customers?email=buyer@example.com" \
-H "x-api-key: sk_test_xxx"
```
```json
{ "object": "list", "data": [ { "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "object": "customer",
"email": "buyer@example.com", "name": "Ada Lovelace", "phone": "+15551230000",
"created": 1733600000, "livemode": false, "metadata": {},
"invoice_settings": { "default_payment_method": null }, "address": null } ],
"has_more": false }
```
Without `email` the list is still cursor-paginated (`limit`, `starting_after`) and
accepts the same `created` window as charges.
---
## Save a card
Tokenize the card in-page (`ev:` tokens) and attach it to the customer. The card is
vaulted; the PAN never reaches you in the clear.
```bash
curl https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "payment_method": { "token": "ev:...", "cvv": "ev:...", "exp_month": 12, "exp_year": 2030 }, "set_default": true }'
```
```json
{ "id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461", "object": "payment_method", "customer": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "type": "card",
"card": { "brand": "Visa", "last4": "4242" } }
```
> The card must be nested under **`payment_method`**. A top-level `card` key is not read,
> and the request fails with `parameter_invalid: payment_method is required`.
**This rail accepts `ev:` tokens only.** A raw card number is rejected, and so is a `wp:`
token. Only the tokenization service holds the key that opens a `wp:` envelope, so a `wp:`
value stored here would be a blob the vault could never reopen, and every later charge off
that saved card would fail.
The `ev:test_token_pan_XXXX` labels from [`POST /v1/test/tokens`](/developers/15-testing.md) work
here too. They expand at the gateway, the same way a first charge does. You can vault a
card from curl without a browser.
Two defaults that are silent rather than loud:
- **`exp_month` / `exp_year`** default to **12 / 2030** when omitted, instead of being
rejected. The card charges fine today but will not expire when the real card does, so the
buyer's renewals keep hitting a dead card. Send the real expiry, as numbers.
- **`set_default`** defaults to `false`. The first card you attach does not automatically
become the default, pass `set_default: true` if this card should be the one
subscriptions and later charges bill.
The response carries only what the vault returns: `id`, `brand`, `last4`. There is no
`exp_month`, `exp_year`, `default` or `created` on it, keep your own record of the expiry
you sent if you need to display it.
List them with `GET /v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods`;
delete one with
`DELETE /v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods/pm_bb90c8f9-683b-4e75-8772-da2374a25461`
(or `DELETE /v1/customers/payment_methods/pm_bb90c8f9-683b-4e75-8772-da2374a25461?customer=cus_4023ec41-ca5f-4153-859f-c30c960a599c`).
The list is **not paginated**: it returns every saved card for the customer in one response,
and `has_more` is always `false`.
---
## Charge a saved card
For renewals and other merchant-initiated charges, call `POST /v1/charges` with the saved
card's id as `payment_method.payment_method_id` (`pm_` or a bare UUID). Set
`recurring: true` for a subscription renewal:
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "amount": 2000, "currency": "usd",
"payment_method": { "payment_method_id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461" },
"recurring": true,
"customer": { "email": "jane@example.com" } }'
```
Omitting `recurring` or setting it to `false` makes a saved-card charge a subsequent
unscheduled merchant-initiated transaction (MIT); `true` makes it a subsequent recurring
MIT. Only booleans are accepted, and `true` is rejected for a fresh card, wallet, `wp_token`
or ACH with `400 parameter_invalid`, `param: "recurring"`. A new renewal needs a new
idempotency key. Reusing a key with a different `recurring` value is an
`idempotency_error`; omitted and `false` are equivalent.
When the cardholder is paying now with a card they saved (on your checkout, not a renewal),
send `"initiated_by": "customer"` so the charge goes out as customer-initiated on a stored
credential. Your 3-D Secure rules then apply to it as in the Payment Element: the charge may
answer `requires_action` (resubmit with `three_ds.session_id` after the buyer's challenge)
or, for a card with no encrypted copy, `soft_decline_3ds` (ask for the card again). The
default, `"merchant"`, is the MIT above. See
[Charges → Charge a saved card](/developers/03-charges.md#charge-a-saved-card).
Saved-card MITs are never sent to 3-D Secure: nobody is there to complete a challenge, and
card networks exempt them. A customer flagged **Require 3-D Secure**, the 3DS risk band, a
fraud rule asking for 3DS, or the route's **Attempt 3DS** does not decline them; they are
charged without 3DS. A fraud rule that blocks still blocks them. When the same customer
pays in the [Payment Element](/developers/08b-payment-element.md), or on your checkout with
`"initiated_by": "customer"`, their saved card is authenticated
([3DS on a saved card](/developers/07-3ds.md#3ds-on-a-saved-card)).
Each charge on a saved card references the payment the card was saved from (the initial
customer-initiated transaction), as the card networks expect; you do not pass anything for
this. A card saved before that reference was kept, or added without a payment, has none.
Installments are not supported. Transaction reporting classifies MITs broadly as recurring;
it does not distinguish unscheduled MITs from subscription renewals.
Two shapes that catch people out:
- **A bare-string `payment_method` is a saved-card id, not a card token.** Sending
`"payment_method": "pm_bb90c8f9-683b-4e75-8772-da2374a25461"` is wrapped as
`payment_method_id` and charged against the vault. A card token (`ev:` / `wp:v1:`) does **not** belong in that string slot, put it
on `card.number`, `card.wp_token`, or `payment_method.token`. The object form above
(`payment_method.payment_method_id`) is the unambiguous way to charge a saved card.
- **`customer` on a charge is an object, not a customer id.** It carries buyer details,
`email`, `name`, `phone`, and only the email is used, to tag the charge with the buyer.
Sending `"customer": "CUS-1042"` is not an error; the field is simply **ignored**, and
the charge records no buyer email at all. The customer id is not part of the charge
body, what ties the charge to the saved card is `payment_method_id`.
A saved-card id is scoped to the merchant account that vaulted it, so a card belonging to
another account cannot be charged with your key. Beyond that, the id is not cross-checked
against a `customer` you name in the body (the body has no customer id to check it
against) so if you track which card belongs to which buyer, enforce that pairing on your
side before you charge.
========================================================================
# Subscriptions
Recurring billing on a customer's saved card. You pick a plan, name the customer, and we
take the opening charge and every renewal after it, running fraud screening and routing on
each one. 3-D Secure runs only while the buyer is present: renewals are merchant-initiated and
are charged without it.
```
POST https://api.worthypayments.com/v1/plans
GET https://api.worthypayments.com/v1/plans
GET https://api.worthypayments.com/v1/plans/{id}
POST https://api.worthypayments.com/v1/subscriptions
GET https://api.worthypayments.com/v1/subscriptions
GET https://api.worthypayments.com/v1/subscriptions/{id}
PATCH https://api.worthypayments.com/v1/subscriptions/{id}
DELETE https://api.worthypayments.com/v1/subscriptions/{id}
```
| Scope | Grants |
|-------|--------|
| `plans:read` | List and fetch plans |
| `plans:write` | Create a plan, **server-only**, a `pk_` is refused |
| `subscriptions:read` | List and fetch subscriptions |
| `subscriptions:write` | Start, modify and cancel, **server-only**, a `pk_` is refused |
---
## Before you start
You need two things, and only one of them comes from the API.
**A plan.** Create one with `POST /v1/plans` (name, amount in cents, interval
`month`/`year`/`week`/`day`) or in the dashboard under **Billing → Plans**. Both write
the same object; `GET /v1/plans` lists it either way.
**A customer.** With a stored payment method, create charges immediately. That first
charge is not yet a listable invoice, calendar renewal and [test
bill-now](#advance-a-test-subscription) are. Without a card, create returns `status:
"incomplete"` and a `latest_invoice.payment_intent.client_secret` you pass to the existing
[Payment Element](/developers/08b-payment-element.md), same iframe, no `plan` / `interval` on the
Element. See [Customers & Saved Cards](/developers/18-customers.md) for attaching a card up front.
---
## 1. Create a plan
```bash
curl -X POST https://api.worthypayments.com/v1/plans \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"Pro","amount":1999,"currency":"usd","interval":"month"}'
```
```json
{
"id": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"object": "plan",
"name": "Pro",
"amount": 1999,
"currency": "usd",
"interval": "MONTH",
"interval_count": 1
}
```
`GET /v1/plans` lists it. Dashboard **Billing → Plans** writes the same object.
## 2. Resolve the plan
```bash
curl "https://api.worthypayments.com/v1/plans?active=true" \
-H "x-api-key: sk_test_xxx"
```
```json
{
"object": "list",
"data": [
{
"id": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"object": "plan",
"name": "Pro monthly",
"amount": 2900,
"currency": "usd",
"interval": "MONTH",
"interval_count": 1,
"trial_days": 14,
"active": true,
"archived": false,
"subscription_count": 42
}
],
"has_more": false
}
```
`amount` is **minor units** like everywhere else on this API, `2900` is $29.00. The
billing period is `interval` × `interval_count`, so a quarterly plan is `MONTH` × `3`.
Archived plans are hidden unless you pass `include_archived=true`. An archived plan
cannot take a new subscription, so an id from that list is one `POST /v1/subscriptions`
will refuse.
`id` is the exact value the subscription object reports as its `plan`, so you can match
the two directly.
---
## 3. Start the subscription
`POST /subscriptions`
| Field | Required | Notes |
|-------|:---:|-------|
| `customer` | Yes | `cus_...`. Missing it is `400 parameter_invalid`. |
| `plan` | Yes | `plan_...` from step 1. The bare uuid is accepted too. |
| `quantity` | No | Seats / units. Defaults to `1`. |
| `coupon` | No | A coupon id to apply. |
| `default_payment_method` | No | `pm_...`. Omitted, the customer's default is used. |
| `collection_method` | No | `CHARGE_AUTOMATICALLY` (default, omit it) charges the saved card and writes a **PAID** invoice on **renewal**. `SEND_INVOICE` emails an OPEN invoice on renewal. Opening create still charges when a card is on file and does not mint the first invoice (see [Invoices](/developers/18c-invoices.md).) |
| `payment_behavior` | No | `default_incomplete`, skip the opening charge even when a card is on file and return the Payment Element secret. Omit it to charge a saved card immediately. |
| `Idempotency-Key` | **Yes** | Header. See below. |
```bash
curl https://api.worthypayments.com/v1/subscriptions \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: sub_order_1234" \
-H "Content-Type: application/json" \
-d '{
"customer": "cus_9fK2pQ",
"plan": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"payment_behavior": "default_incomplete"
}'
```
> **This call moves money when the customer already has a card and you omit
> `payment_behavior`.** Unless the plan is trialing, the opening charge is taken as part
> of creation, which is why it needs a real [`Idempotency-Key`](/developers/12-idempotency.md) keyed
> on your order, not a fresh UUID per click. A retried `POST` must resolve to the same
> subscription rather than starting a second one on the same card. For the same reason a
> `FRAUD_ONLY` key can never reach this route; see [Integration
> modes](/developers/25-integration-modes.md). **`payment_behavior=default_incomplete`, or no card
> on file:** the same `POST` answers `status: "incomplete"` and
> `latest_invoice.payment_intent.client_secret`. Confirm that secret with
> `mountPaymentElement` (same as a one-off intent). Completing the Element takes the
> opening charge and activates the subscription. There is still no listable invoice on
> `GET /v1/invoices` until a later renewal. Do not put `plan` or `interval` on the
> Element.
The incomplete response (the Element loop):
```json
{
"id": "sub_8c1e...",
"object": "subscription",
"status": "incomplete",
"customer": "cus_9fK2pQ",
"plan": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"quantity": 1,
"latest_invoice": {
"object": "invoice",
"payment_intent": {
"object": "payment_intent",
"status": "requires_payment_method",
"client_secret": "pi_abc_secret_sess_elem_1"
}
},
"cancel_at_period_end": false
}
```
`next_billing_at` is the field to store: it is when money moves next.
---
## Advance a test subscription
There is no Test Clock. In **test mode only**, run the calendar renewal now:
```
POST https://api.worthypayments.com/v1/test/subscriptions/{id}/bill
```
Same function the renew worker uses. `CHARGE_AUTOMATICALLY` (default) charges the saved
card for the next period **and** writes a **PAID** invoice listed on `GET /v1/invoices`.
`SEND_INVOICE` finalizes and emails an OPEN invoice, no card charge. A LIVE key is refused
with `400 test_mode_only` before billing is touched. Scope `subscriptions:write`.
[`Idempotency-Key`](/developers/12-idempotency.md) is required. A new key per advance.
The charge id is not the invoice id. A decline leaves the sub `past_due` and emits
`subscription.past_due`. There is no `invoice.payment_failed` event.
Retries of a declined renewal are ours only for subscriptions we bill. If your own system
schedules renewals (a WooCommerce Subscriptions store, your own billing job) and charges a
saved card with [`POST /v1/charges`](/developers/03-charges.md#charge-a-saved-card), those renewals
are yours to retry: we decline once and never retry them on our own, so the buyer is never
charged by two retry schedules for one period.
```bash
curl -X POST https://api.worthypayments.com/v1/test/subscriptions/sub_8c1e.../bill \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: bill_sub_8c1e_period_2"
```
---
## 4. Modify one
`PATCH /subscriptions/{id}`, **exactly one intention per call.**
| Field | Effect |
|-------|--------|
| `plan` | Move to a different plan. Upstream prorates. |
| `paused: true` \| `false` | Suspend or resume billing. Optionally `resumes_at` (ISO-8601) with `true` to schedule the resume. |
| `cancel_at_period_end: true` | Schedule a cancellation for the end of the current period. Optionally `reason`. |
Setting two of them is a `400`, not a guess: each routes to a different operation with
different money semantics (a plan change prorates, a pause suspends billing) and there is
no defined order for combining them.
> **`cancel_at_period_end: false` is refused, not accepted as a no-op.** There is no
> uncancel: taking `false` would read as "this un-cancelled" while the subscription still
> lapsed at period end. To keep the customer, start a new subscription.
Unlike `POST`, `PATCH` takes **no** `Idempotency-Key`. These are state transitions
expressed as the state you want, so repeating one lands in the same place (pausing a
paused subscription is a no-op) and a claim would only let a legitimate correction be
swallowed as a duplicate.
---
## 5. Cancel
`DELETE /subscriptions/{id}`
```json
{ "immediately": true, "reason": "customer_request" }
```
Without `immediately`, the subscription runs to the end of the paid period and stops
there. With it, it ends now. Neither refunds the period already charged, issue a
[refund](/developers/06-refunds.md) if you owe one.
---
## Reconciling
Poll nothing. The lifecycle arrives on [webhooks](/developers/16-webhooks.md):
| Event | Meaning |
|-------|---------|
| `subscription.created` | Started, opening charge taken |
| `subscription.renewed` | A period was billed |
| `subscription.updated` | Plan, quantity or pause state changed |
| `subscription.past_due` | A renewal failed; retries are in progress |
| `subscription.suspended` | Retries were exhausted |
| `subscription.canceled` | Ended |
| `subscription.usage_exceeded` | A metered plan passed its limit |
`subscription.past_due` is the one to build for. There is **no `invoice.payment_failed`**.
A renewal failing is normal (cards expire) and the customer keeps their access until
`subscription.suspended` says otherwise. Treating `past_due` as cancellation cuts off
people who are about to pay.
---
## What is not here
- **Editing a plan.** Dashboard only. Create is `POST /v1/plans`.
- **An uncancel.** See the `PATCH` note.
- **Invoice create / finalize / pay / void.** List and retrieve are on
`GET /v1/invoices`. Email an OPEN invoice with `POST /v1/invoices/{id}/send`
(`invoices:write`). Finalize, pay and void stay in the dashboard. Auto-charge
**renewals** mint a PAID invoice; opening create does not. See
[Invoices](/developers/18c-invoices.md).
========================================================================
# Invoices
List and retrieve invoices this account has issued. Amounts are integer minor units
(cents). There is no public `POST /v1/invoices`.
`hosted_invoice_url` is the customer-facing page. It is `null` on a draft and stamped
when the invoice is finalized:
```
https://app.worthypayments.com/invoices/{id}/view?token=…
```
There is no `invoice.paygeyt.com` host. An empty `data` array is a successful list,
not a missing route.
A **CHARGE_AUTOMATICALLY** subscription (the default: we charge the saved card) writes a
**PAID** invoice on every successful **renewal**, including `POST
/v1/test/subscriptions/{id}/bill`. `hosted_invoice_url` is the receipt page. Opening `POST
/v1/subscriptions` still takes the charge without minting an invoice row in this release
(reconcile that first period against [charges](/developers/03-charges.md). There is no
`invoice.payment_failed` webhook) a failed card renewal is `subscription.past_due`.
A **SEND_INVOICE** subscription writes an **OPEN** invoice on renewal and emails
it (`invoice.sent`). Opening create does not mint the first invoice today. Pay
it from the dashboard in this release.
An **incomplete** subscription (`payment_behavior=default_incomplete`, or no
card) has no listable invoice yet. The Element secret is on the create response
as `latest_invoice.payment_intent.client_secret`.
```
GET https://api.worthypayments.com/v1/invoices
GET https://api.worthypayments.com/v1/invoices/{id}
POST https://api.worthypayments.com/v1/invoices/{id}/send
```
| Scope | Grants |
|-------|--------|
| `invoices:read` | List and fetch invoices |
| `invoices:write` | Send an invoice email, **server-only**, a `pk_` is refused |
```bash
curl "https://api.worthypayments.com/v1/invoices?limit=10" \
-H "x-api-key: sk_test_xxx"
```
```json
{
"object": "list",
"data": [
{
"id": "in_7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3",
"object": "invoice",
"status": "PAID",
"total": 1999,
"amount_due": 0,
"currency": "usd",
"hosted_invoice_url": "https://app.worthypayments.com/invoices/7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3/view?token=…"
}
],
"has_more": false
}
```
Filter with `customer` (a `cus_…` id) and `status`. Page with `starting_after`.
## Send
`POST /v1/invoices/{id}/send` emails an **OPEN** (or later) invoice and emits
`invoice.sent`. A draft is refused (`finalize first`, finalize stays in the dashboard).
Re-sending is allowed. `invoices:write` is server-only. New secrets include it; an older
key must be granted it under **Developers → API keys**.
```bash
curl -X POST https://api.worthypayments.com/v1/invoices/in_7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3/send \
-H "x-api-key: sk_test_xxx"
```
There is **no `invoice.payment_failed` event**. A card renewal that declines is
`subscription.past_due`, subscribe to that. See [Subscriptions](/developers/18b-subscriptions.md).
Finalize, pay and void stay in the dashboard in this release.
To run a renewal in test without waiting for the calendar, see [Testing, advance a test
subscription](/developers/15-testing.md#advance-a-test-subscription).
Related: [Subscriptions](/developers/18b-subscriptions.md).
========================================================================
# Events
Every webhook delivery is also stored as an **event** you can fetch back through the API.
If your endpoint was down and missed a delivery, list events to reconcile. You never lose
data waiting on a webhook.
```
GET /v1/events GET /v1/events/:id
POST/GET/DELETE /v1/webhook-endpoints
```
Scope: `events:read`, `webhooks:read`, `webhooks:write` (write server-only).
**One event stream, whichever way the payment was taken.** A charge taken through the
[Payment Element](/developers/08b-payment-element.md) and one built on your own server emit the
same `charge.*` events. The Element is a front door onto the same money path, not a
separate one. If the Element reports `processing`, this is where the outcome arrives.
---
## List events
```bash
curl "https://api.worthypayments.com/v1/events?limit=20" \
-H "x-api-key: sk_test_xxx"
```
```json
{
"object": "list",
"data": [
{
"id": "evt_3f2b4a86-b0e7-4d41-9c6f-2a915c1d90a7", "object": "event", "type": "charge.succeeded",
"created": 1733600000, "event_id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"data": {
"id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915", "object": "event", "api_version": "2026-01-01",
"created": 1733600000, "livemode": false, "type": "charge.succeeded",
"data": { "object": { "transactionId": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": { "minorUnits": "2000",
"currencyCode": "USD" }, "instrument": { "brand": "visa", "last4": "4242" }, ... } }
}
}
],
"has_more": false,
"next_cursor": null
}
```
⚠️ **The polled item WRAPS the delivered envelope. It is not the same shape.** Each list
item has five keys (`id`, `object`, `type`, `created`, `event_id`), and `data` holds the
entire envelope that was POSTed to your endpoint. So the resource sits at
**`data.data.object`**, one level deeper than in the webhook body, and `api_version` /
`livemode` are at `data.api_version` / `data.livemode` rather than at the top.
⚠️ **The item's `id` and the envelope's `id` are different values.** The outer `id`
identifies the *delivery*. One row per subscribed endpoint. The envelope's own `id`,
inside `data`, identifies the *occurrence*, and the item repeats it as `event_id`. So
`item.data.id === item.event_id`, and `item.data.id !== item.id`. Both are
`evt_`-prefixed, which is exactly why they are easy to conflate.
The practical consequence: `event.data.object.transactionId` works on a **pushed** body and
returns `undefined` on a **polled** one. Reach for `event.data.data.object.transactionId`
when polling, or unwrap once (`const envelope = item.data`) and share the rest of your
handler between both paths.
Page with `limit` and `starting_after`; retrieve one with `GET /v1/events/:id`.
> **Polling reads the occurrence log.** `GET /v1/events` lists every event 07 recorded for
> your merchant in this mode, including charges that happened with **no webhook endpoint
> subscribed**. A push still requires an endpoint; polling does not. The panel's
> per-endpoint delivery log is a different surface (`GET /webhooks/:id/deliveries`).
> ⚠️ **A few rows are recorded but never POSTed, and polling shows them anyway.** When we
> re-drive historical data (a backfill, a re-sync after a provider outage) the delivery
> row is written and marked delivered, but the outbound request is deliberately suppressed
> so a backfill cannot re-notify you about things you were already told about weeks ago.
> Because polling reads that same log, **such an event appears in `GET /v1/events` as a
> normal delivered event that your endpoint never received.** This is the one place where
> the polled feed and what actually arrived at your URL diverge. If you reconcile "did I
> receive everything the feed lists?", expect a small number of legitimate misses; the
> event is real and the state change happened, we simply chose not to re-notify.
> **This event feed is not your source of record. It is a change feed.** Occurrences older
> than the outbox retention window (7 days after they complete) are not kept. Nothing is
> backfilled when you add a webhook later, and you cannot ask us to re-send history. To
> load history, or to re-sync after an outage, read the resource itself: `GET
> /v1/pre_dispute_alerts`, `GET /v1/charges`, `GET /v1/customers` and friends are
> queryable over the full record, and `/v1/pre_dispute_alerts` takes `updated_since` for
> incremental pulls. The durable pattern is *events tell you something changed, the
> resource endpoint tells you what it is now*. That way a missed event is never lost data.
### Filter by type
```bash
curl "https://api.worthypayments.com/v1/events?type=pre_dispute_alert.created&limit=50" \
-H "x-api-key: sk_test_xxx"
```
`type` takes one exact event name. There is no wildcard, and an unrecognised name returns
an empty list rather than an error. The filter is applied before paging, so `limit` still
means "up to N *matching* events" and `has_more` describes the filtered set. Re-send
`type` with each `starting_after` page, the same way you re-send `limit`.
Other query parameters are ignored rather than rejected, so check spelling: a mistyped
`types=` returns *unfiltered* events, not a `400`.
### Poll without missing events: use `created_after`
```bash
curl "https://api.worthypayments.com/v1/events?created_after=1733600000&limit=100" \
-H "x-api-key: sk_test_xxx"
```
> **Anchor every poll on `created_after`, not on the page number.** `starting_after` is a
> 1-based *page number* over a newest-first feed. New deliveries land at the head, so if
> events arrive between your page 1 and your page 2, page 2 starts *later* in the sequence
> than page 1 ended and the rows in between are returned to nobody, silently, with a `200`
> and no gap marker. Store the highest `created` you have processed and pass it as
> `created_after` on the next poll. Then a concurrent insert cannot slide a row past your
> cursor. It accepts unix seconds or RFC3339, and rejects anything else with a `400`
> rather than quietly returning your whole history.
**Two ids, and they answer different questions.**
| Field | What it identifies | Use it to |
|---|---|---|
| `id` | this **delivery**. One row per subscribed endpoint, and the only value `GET /v1/events/:id` resolves | ignore a **retry** of the same delivery |
| `event_id` | the **occurrence**. One value shared by every delivery of it. This is the `X-Webhook-Id` header, and the `id` inside the delivered envelope | ignore **fan-out** across endpoints |
⚠️ **`GET /v1/events/{X-Webhook-Id}` answers `404`.** That header carries the occurrence
id; this endpoint is keyed on the delivery id. If you are holding a pushed body and want
the polled row for it, list with `type` + `created_after` and match on `event_id`. There
is no lookup by occurrence.
A retry reuses the delivery row, so `id` is stable across all attempts of one delivery. But
fan-out writes **one delivery row per subscribed endpoint**: with two endpoints on the same
event type you receive that occurrence twice, under two different `id` values and one shared
`event_id`.
**So deduplicate on `event_id` when you have it**, and fall back to `id` when you do not.
It is emitted only when the producer set it, and an unset value is absent from the JSON
rather than null. Keying on `id` alone still protects you from retries, which is the
common case; it just lets one occurrence through twice if you subscribe two endpoints to
the same type.
> ⚠️ **`starting_after` here is a 1-based PAGE NUMBER, not an object id.** Every other
> list in this API is cursor-paginated and takes the previous page's `next_cursor`;
> `/events` is the exception, and `next_cursor` on this endpoint is simply the next page
> number as a string.
>
> This matters because passing an id fails **silently**: a non-numeric `starting_after`
> is read as page 1, so a loop that pages by the last event's id re-fetches the first
> page forever and never terminates. Pass back the `next_cursor` you were given, and stop
> when `has_more` is `false`.
---
## Managing webhook endpoints
Register an endpoint to receive events. The signing secret is returned **once** on create,
so store it to [verify deliveries](/developers/16-webhooks.md).
```bash
curl https://api.worthypayments.com/v1/webhook-endpoints \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "url": "https://shop.example.com/webhooks", "enabled_events": ["charge.succeeded", "charge.refunded", "charge.failed"] }'
```
```json
{ "id": "we_...", "object": "webhook_endpoint", "url": "https://shop.example.com/webhooks",
"enabled_events": ["charge.succeeded", "charge.refunded", "charge.failed"],
"status": "enabled", "created": 1733600000, "secret": "whsec_..." }
```
- `GET /v1/webhook-endpoints` lists them, with secrets omitted.
- `DELETE /v1/webhook-endpoints/:id` removes one.
- `POST /v1/webhook-endpoints/:id/roll-secret` rotates the signing secret.
---
## Event types
Subscribe to exactly the events you handle when you create the endpoint. Every name
below is deliverable: an event only appears here if a real producer emits it. Names
that had no producer were deliberately removed rather than left in as a black hole
you could subscribe to and never hear from.
**Payments.** `charge.*` is the public API naming; `transaction.*` is the same
underlying event under the dashboard's naming. Subscribe to one family, not both, or
you will handle every payment twice.
| Event | When |
|---|---|
| `charge.succeeded` | A charge was captured successfully |
| `charge.authorized` | A `capture_method: manual` charge was authorized (funds held) |
| `charge.captured` | A previously authorized charge was captured |
| `charge.failed` | A charge was declined |
| `charge.refunded` | A charge was refunded |
| `charge.canceled` | An authorized charge was voided |
| `charge.expired` | An authorized charge nobody captured ran past the card network's window (4 days 18 hours for a Visa merchant-initiated charge, 7 days otherwise) and was canceled. The hold is released and the charge can no longer be captured. `charge.canceled` fires at the same moment |
| `transaction.succeeded` | Dashboard-naming equivalent of `charge.succeeded` |
| `transaction.authorized` | Dashboard-naming equivalent of `charge.authorized` |
| `transaction.failed` | Dashboard-naming equivalent of `charge.failed` |
| `transaction.refunded` | Dashboard-naming equivalent of `charge.refunded` |
| `transaction.voided` | Dashboard-naming equivalent of `charge.canceled` |
| `transaction.expired` | Dashboard-naming equivalent of `charge.expired` |
| `transaction.updated` | A transaction you already know about learned new facts. **Not** a money event (see below) |
| `transaction.enriched` | The same moment as `transaction.updated`, but the payload carries the new values (`detail`, the AVS/CVV codes, the card identity, the ARN, the settlement id) instead of naming them. **Not** a money event either |
| `charge.three_d_secure.challenged` | The issuer required a challenge and it was shown to the buyer |
| `charge.three_d_secure.abandoned` | A challenge was shown and never completed (see below) |
### 3-D Secure: the abandoned challenge
`charge.three_d_secure.abandoned` fires when the buyer reached their bank's challenge page
and walked away.
⚠️ **This produces no other event.** No `charge.failed`, because nothing was declined. The
authentication simply never finished, so no money moved and no decline code exists. Until
this event, that outcome was indistinguishable from a page that never loaded.
It is usually worth a nudge: the shopper's card is fine, they just did not complete the
step. `charge.three_d_secure.challenged` is the informational twin. The challenge was
shown, the payment is still in flight, and a `charge.succeeded` or `charge.failed` will
follow.
Both carry the session id, the transaction id and your customer reference. They do **not**
carry the device or card-hash fields our fraud engine uses internally.
### `transaction.updated`: the first observation is not the last one
A charge is booked from what the gateway said at authorization. Facts keep arriving after
that, and some of them **cannot** be on the first event:
- the **ARN** is minted at clearing, hours later
- the **settlement id** appears when the batch closes
- **AVS/CVV result codes** and the **card identity** (BIN, fingerprint) often arrive on a
fuller later observation than the one that created the row
Until now those were written to your transaction silently. This event tells you they
landed.
```json
{
"type": "transaction.updated",
"data": { "object": {
"transactionId": "TXN-8ac4796a-…",
"updatedFields": ["arn", "settlement_id"],
"source": "GATEWAY_SYNC"
} }
}
```
⛔ **It never means the money changed.** Capture, refund and void keep their own events. If
you fulfil orders on `charge.succeeded`, nothing here changes that, treat this purely as
"re-read this transaction".
**It carries which fields changed, not their values.** Read the new values with
`GET /v1/charges/{id}`.
⚠️ **This is the exception, not the rule, and the reasoning has changed.** We used to
argue that values in an event would be "a second, staler truth". That held while events
were assembled from the incoming request, but a charge event is now snapshotted from the
row inside the same transaction that writes it, so it is not a copy that can drift: it is
the record at that instant. The money events therefore carry a full `detail` object:
`charge.authorized`, `charge.succeeded` / `.captured`, `charge.failed`, `charge.refunded`
and their `transaction.*` twins, plus `customer.created` / `.updated`. **`charge.canceled`
/ `transaction.voided`, `charge.expired` / `transaction.expired` and both
`charge.three_d_secure.*` events do not**. A void or an expiry carries
its `orderReference` and `metadata` at the top level instead, and the 3-D Secure pair are authentication
signals: session id, transaction id and your customer reference, with the fraud hashes
those messages also hold stripped before delivery.
`transaction.updated` stays field-names-only because it is genuinely different: it fires
when a LATER observation lands (an ARN at clearing, a settlement id at batch close), often
long after the charge, and what it reports is that the resource moved. Not a
self-contained fact about a moment. `transaction.enriched` is its value-carrying twin for
the same moment: it re-reads the row after the merge and ships `detail`, so a webhook-only
integration does not have to call back. Subscribe to one or the other, not both.
**It does not fire on every re-observation.** We compare the row before and after the
write, so a later observation of a transaction we already fully know changes nothing and
sends nothing. `updatedFields` is never empty.
`source` is `GATEWAY_SYNC` today. The payment was re-observed at the gateway. It is named
rather than implied so a future operator-driven correction is distinguishable without a
new event type.
**Saved cards.**
| Event | When |
|---|---|
| `payment_method.attached` | A buyer saved a card for future payments |
| `payment_method.updated` | A saved card changed, usually a new expiry after the issuer re-issued it |
| `payment_method.detached` | A saved card was removed. Stop billing it |
These carry the payment method id, the customer id, and the brand/last4/expiry a saved-card
list renders. They deliberately do **not** carry the vault reference or the card
fingerprint: those are handles to the stored card and belong on our side of the boundary.
**Setup intents** (a card saved and verified with no payment, `POST /v1/setup_intents`).
| Event | When |
|---|---|
| `setup_intent.succeeded` | A setup intent verified and saved a card with no payment. `payment_method` names the saved card |
| `setup_intent.setup_failed` | A setup intent's card could not be verified (declined, refused by fraud screening, or not verifiable right now). Nothing was saved; the buyer can try again |
`setup_intent.succeeded` carries `setup_intent_id`, `customer_id`, `payment_method_id`,
`usage` and the card's brand/last4/expiry; the card itself also sends
`payment_method.attached` (or `payment_method.updated` when the customer already had it).
`setup_intent.setup_failed` carries `last_setup_error` (`code`, `decline_code`, `message`) and
`failed_attempts`. Neither carries the client secret or any card token.
`payment_method.detached` is the one to wire first if you bill on file. It is how you
learn to stop.
**Customers.**
| Event | When |
|---|---|
| `customer.created` | A new customer was created |
| `customer.updated` | A customer was updated |
| `customer.deleted` | A customer was deleted |
`customer.created` and `customer.updated` carry a `detail` object: the customer record as we
stored it, so you can keep your own CRM in step without reading back.
```json
{
"type": "customer.updated",
"data": { "object": {
"customerId": "8ac4796a-…",
"changedFields": ["first_name", "address_line1"],
"detail": {
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+15551234567",
"addressLine1": "12 Analytical Way",
"city": "London",
"postalCode": "EC1A",
"country": "GB",
"isVerified": true,
"totalTransactions": "12",
"totalAmountCents": "906000",
"disputeCount": "1",
"lastTransactionAt": "2026-08-20T00:00:00Z"
}
} }
}
```
`changedFields` still tells you **what** moved; `detail` now tells you what it moved **to**.
Before this, an update named the field and you had to call us for the value.
⚠️ **`changedFields` and `detail` do not always spell a field the same way.** The names in
`changedFields` are field names as the customer API takes them (`first_name`,
`address_line1`); the keys inside `detail` are the record's own (`firstName`,
`addressLine1`). For `email`, `phone`, `city`, `state` and `country` the two coincide, which
is why the difference is easy to miss. If you branch on `changedFields`, compare
case-insensitively with `_` stripped, and always read the new value out of `detail` rather
than trying to index it by the changed-field name.
⚠️ **Sparse where the data is sparse, and honestly so.** Measured across 50,000 live
customers: email and name are present on 100%, phone on 96%, address on 59%, lifetime
totals on 43%. An absent field means the buyer never supplied one. Not that we dropped it.
⚠️ **A zero total is absent, not `"0"`.** The payload omits every field still holding its
default, so a customer created but never charged carries no `totalTransactions`, no
`totalAmountCents`, no `disputeCount` at all, and `isVerified` is absent rather than `false`.
Read counts as `Number(detail.totalTransactions ?? 0)` and booleans as
`detail.isVerified === true`. "No key" and "zero" are the same fact here, so do not use the
key's presence to decide whether a customer has history.
⚠️ **`customer.deleted` carries no detail.** By the time it fires the record is already
anonymised. There is nothing left to send, and that is the point of the event.
⛔ **Amounts and counts are int64 and cross the wire as JSON strings.** `"906000"`, not
`906000`. Parse them as integers, not floats: a lifetime volume past 2^53 loses precision as a
JavaScript number.
**Checkout and orders.**
| Event | When |
|---|---|
| `checkout.session.completed` | A checkout session completed. A payment intent's carries its `metadata` as it stood when paid (absent when it has none) |
| `payment_link.completed` | A payment-link checkout completed |
| `payment_link.created` | A payment link was created |
| `payment_link.updated` | A **live** link was edited. Carries `changedFields`: the names, not the values |
| `payment_link.deactivated` | A payment link was deactivated |
| `inventory.low_stock` | A store product hit its low-stock threshold on a sale |
| `order.created` | An order was placed. **Fires together with `.paid` and `.completed`** (see below) |
| `order.paid` | An order was paid |
| `order.completed` | Alias of `order.paid` |
| `order.fulfilled` | An order was fulfilled |
| `order.cancelled` | An order was cancelled |
> ⚠️ **`order.created`, `order.paid` and `order.completed` are one moment, not three.** An
> order row is written already paid, and that single write emits all three names at once,
> `order.completed` is an alias of `order.paid`, and `order.created` is not an earlier
> stage you can act on before payment lands. An endpoint subscribed to all three receives
> the same order three times, with three event ids and effectively the same timestamp.
> Subscribe to one and treat it as "an order exists and is paid". This is the same
> simultaneous-fan-out rule as `dispute.won` → `dispute.closed` and `subscription.renewed`
> → `subscription.updated`.
> The four `order.*` events carry **`data.object.order`**, the stored order record: the
> line items, the shipping and billing addresses, the buyer's contact details, and, on the
> `.fulfilled` delivery that reports a tracking number, `trackingNumber` and `carrier`.
> ⚠️ Marking an order shipped and adding a tracking number are separate writes, and each
> is a fulfilment fact. Marking it shipped emits `order.fulfilled` with
> `order.trackingNumber` empty; adding a tracking number the order did not already hold
> emits it again, that time carrying the number. Either write can come first, and when the
> tracking number arrives together with the shipping mark there is only one delivery. Key
> on the order id and treat the second as an update, not a duplicate; re-marking an
> already-shipped order, or re-sending a tracking number we already hold, emits nothing
> further. ⚠️ **Two of its fields are always empty on the webhook.** `events` (the
> order's own timeline) is simply not loaded: the event carries the order row, not its
> relations. `digitalDeliveries` is removed on purpose, because a delivery summary carries
> a redeemable code and a signed download link that belong to the buyer rather than in a
> webhook body. Neither is recoverable over the public API, orders are a dashboard
> surface, and there is no `GET /v1/orders/{id}`. Read them in the dashboard.
> **`shippingAddressJson` and `billingAddressJson` are JSON strings**, not objects. Parse
> them.
> **`store.*` and `product.*` are not deliverable.** They record administrative changes
> we make to a catalogue, not changes you make. Your own product edits are already yours.
**Subscriptions and invoices.**
| Event | When |
|---|---|
| `subscription.created` | A subscription was created |
| `subscription.updated` | A subscription was updated |
| `subscription.canceled` | A subscription was canceled |
| `subscription.paused` | A subscription was paused |
| `subscription.resumed` | A subscription was resumed |
| `subscription.renewed` | A subscription renewed for a new billing period |
| `subscription.past_due` | A renewal payment failed |
| `subscription.suspended` | A subscription was suspended after a hard decline |
| `subscription.usage_exceeded` | Metered usage exceeded the subscription limit |
| `invoice.created` | An invoice was created |
| `invoice.finalized` | An invoice was finalized. The number and the hosted URLs are stamped here |
| `invoice.sent` | We emailed the invoice to your customer |
| `invoice.overdue` | An invoice passed its due date unpaid |
| `invoice.paid` | An invoice was paid |
| `invoice.payment_succeeded` | An invoice payment succeeded |
| `invoice.voided` | An invoice was voided |
There is **no `invoice.payment_failed`**. A card renewal that declines is
`subscription.past_due`.
> Every `subscription.*` and `invoice.*` event carries **`data.object.detail`**, the full
> stored record. See [The subscription block](/developers/16-webhooks.md#the-subscription-block) and
> [The invoice block](/developers/16-webhooks.md#the-invoice-block).
> **`subscription.usage_exceeded` is the exception.** It is a metering signal rather than
> a lifecycle change, and carries `currentUsage` / `usageLimit` / `percentOver` instead.
> ⚠️ **`detail.amount`, `detail.interval` and `detail.intervalCount` ride one priced line,
> so they arrive or vanish together.** They are on all eight `subscription.*` events that
> carry `detail`; they drop out together only when no priced line is stored against the
> subscription. `amount` is the recurring **base**. The plan price × `quantity`, before
> coupon, downgrade credit and tax. Not the settled charge. An absent `amount` means "not
> stated on this event", never "bills nothing". ⚠️ **`detail.dunningState` is always
> `"NONE"`, and `detail.dunningAttempts` is always a zero the payload omits.** Billing
> runs no retry clock; decline recovery lives on the `decline_recovery.*` events.
> `detail.nextDunningAt` is absent too except after a retry you trigger yourself, where it
> is the *past* moment that attempt was claimed. Do not read the dunning trio as "nobody
> is retrying". Two fields carry more than they look like: `detail.cancelAtPeriodEnd` on
> `subscription.canceled` decides whether access ends now or at the period boundary, and
> `detail.amountRemaining` on any invoice event distinguishes a **partially** paid invoice
> from a paid one, read it as `Number(detail.amountRemaining?.minorUnits ?? 0)`, because a
> zero money arrives as `{"currencyCode":"USD"}` with no `minorUnits` key.
> **`invoice.sent` also carries a live `hostedInvoiceUrl` and `checkoutUrl` at the top
> level**, beside `detail` rather than inside it. Both are payable by whoever holds them
> (see [the invoice block](/developers/16-webhooks.md#the-invoice-block).)
**Disputes.**
| Event | When |
|---|---|
| `dispute.created` | A dispute was opened. **Also fires for a pre-dispute alert** (Ethoca/CDRN/RDR), which is a warning, not a chargeback, branch on `pre_dispute_alert.created` if you need to tell them apart |
| `dispute.updated` | Evidence was submitted for a dispute, or the dispute was unlinked from or moved off the sale it named (see below) |
| `dispute.won` | You won a dispute, **also delivers `dispute.closed`** |
| `dispute.lost` | You lost a dispute, **also delivers `dispute.closed`**; the payload adds `fee` |
| `dispute.closed` | A dispute reached a terminal state. Delivered **two ways** (see below) |
| `dispute.funds_withdrawn` | Disputed funds were withdrawn from your balance |
| `dispute.funds_reinstated` | Disputed funds were returned after a win |
**`dispute.closed` arrives from two different producers, with two different shapes.**
| Source | Carries | Tell them apart by |
|---|---|---|
| the second delivery of a `.won` / `.lost` | `amount`, `fee` (on lost), the full `dispute` | `amount` is present |
| a **no-winner close**. The provider reported the case terminal without saying who won (Nuvei never reports a winner) | `provider`, the full `dispute`, **no `amount`** | `provider` is present, `amount` is absent |
Both mean "this dispute is over". Only the first one implies money moved, so branch on the
presence of `amount` before you book anything.
Most `dispute.*` payloads carry the **full `dispute` object** under `data.object.dispute`.
The same one `GET /v1/disputes` returns, from the same mapper, so a pushed event and a
polled one never disagree. That is where `nmi_transaction_id`, `source_acquirer` (Payarc /
Nuvei / Maverick Payments), the card BIN/last4, `respond_by` and `reason_message` live.
`dispute.updated` and `dispute.funds_withdrawn` carry it as well. The object is declared
on every `dispute.*` event and filled from the row inside the transaction that wrote it.
⚠️ **`dispute.funds_reinstated` is the one name where the nested object is genuinely
optional.** Six paths return held funds and each raises this event. One of them attaches
the object; the other five send the flat fields alone. Four of those five raise
`dispute.won` in the same transaction *with* the object, so read the card and the gateway
id off that. The fifth is a pre-dispute alert freeing the reserve, and it raises
`dispute.won` only when the alert is refunded. A dismissed one raises no `dispute.won`, so
read the dispute back with `GET /v1/disputes/{disputeId}`.
⚠️ **`nmi_transaction_id` inside `dispute` is best-effort, not a guarantee.** It is filled by
joining the linked sale; an unmatched dispute, or one whose sale has not synced yet, carries an
empty value. Treat it as "present when we could resolve it".
The flat keys beside it are the disputes engine's own event and differ per name:
camelCase, `disputeId` rather than `id`, `amount` as a `{minorUnits, currencyCode}`
object. They stay for subscribers written against them, but they cannot tell you which
charge the dispute names in *your* system, read `dispute` first. `pre_dispute_alert.*`
works the same way, under `data.object.alert`. Field-by-field breakdown in [Webhooks →
Dispute payloads](/developers/16-webhooks.md#dispute-payloads) and [Alert
payloads](/developers/16-webhooks.md#pre-dispute-alert-payloads).
| `dispute_recovery.opened` | A customer crossed the dispute threshold and was invoiced + blocked |
| `dispute_recovery.resolved` | A dispute recovery settled and the block lifted |
| `pre_dispute_alert.created` | A pre-dispute alert (Ethoca / Verifi CDRN / Visa RDR) arrived, before it can become a chargeback |
| `pre_dispute_alert.updated` | A pre-dispute alert changed: linked to a sale, unlinked or moved to another sale, refunded, dismissed or escalated. Carries `outcome`, the link-change keys below when the sale changed, and `refundStatus=SETTLED` once the gateway confirms the refund |
> **`pre_dispute_alert.created` and `dispute.created` can both fire for the same
> event.** They are the same underlying occurrence in one of its flavours, so an
> endpoint subscribed to both receives it twice, with two different `type` values and
> two different event ids. Dedupe on the payload's alert or dispute id rather than on
> `X-Webhook-Id` if you subscribe to both.
>
> `refundStatus` is worth reading carefully: it distinguishes a refund this platform
> **requested** from one the gateway has **confirmed settled**. Acting on the first as
> though it were the second is how a chargeback still lands after you believed it was
> refunded.
**A case that left its sale.** When we unlink a pre-dispute alert or a dispute from a sale,
move it to another one, or give a dispute we delivered without a sale its sale after review,
`pre_dispute_alert.updated` or `dispute.updated` carries three extra keys, on that update
only:
| Key | Meaning |
|---|---|
| `previousTransactionId` | The sale the case was linked to before the change (absent on `LINKED`) |
| `linkChange` | `UNLINKED` (no sale now; `transactionId` is absent), `RELINKED` (moved to `transactionId`) or `LINKED` (a dispute delivered without a sale now names `transactionId`) |
| `linkChangeReason` | `EVIDENCE_CONFLICT`, `DECLINED_ATTEMPT`, `NOT_A_SALE`, `ARN_COLLISION`, `OPERATOR`, `KOUNT_TWIN_SYNC` or `REMATCH` |
If you acted on the old link, for example flagged the customer behind
`previousTransactionId`, undo it for this case. Each real change is delivered once, and a
re-import of historical data never delivers one. What each reason means, and an example
body: [Webhooks → Link changes](/developers/16-webhooks.md#link-changes).
**Decline recovery (Smart Retry).** Terminal outcomes only.
| Event | When |
|---|---|
| `decline_recovery.recovered` | A retry succeeded and the customer was charged |
| `decline_recovery.failed` | A recovery case was exhausted or retired without recovery |
| `decline_recovery.hard_declined` | The decline cannot be retried at all (lost/stolen/expired/fraud); the customer must supply a new card |
**Payouts.**
| Event | When |
|---|---|
| `payout.created` | A payout was created |
| `payout.paid` | A payout was paid |
| `payout.failed` | A payout failed |
The dashboard's **Developers → Webhooks → Add endpoint** dialog lists this same set,
served from the API (`GET /webhooks/events`), so the two cannot drift apart. Creating
an endpoint with a name that is not on this list is rejected rather than silently
accepted.
========================================================================
# Versioning
The API is versioned with **dated versions** so a breaking change never breaks your
integration without warning. You pin a version and upgrade on your own schedule.
---
## How it works
Every request resolves an effective version like this:
1. The **`API-Version`** request header, if it is a well-formed `YYYY-MM-DD`.
2. Otherwise the platform's **current version** (`2026-01-01`).
A version can be stored on the API key in the dashboard. That pin is **not consulted
today**. There is only one behavioural version, so the header (when well-formed) is echoed
and every other request gets the current default. The pin records intent for when a second
version exists; do not treat it as selecting a contract yet.
Every response echoes the resolved version in the **`API-Version`** response header, and a
unique **`Request-Id`** header for support correlation.
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_live_xxx" \
-H "API-Version: 2026-01-01" \
..
# Response headers:
# API-Version: 2026-01-01
# Request-Id: req_8f2c..
```
The current version is **`2026-01-01`**. The same version stamps the `api_version` field on
every webhook event.
> **There is currently only one version.** `2026-01-01` is both the current version and
> the oldest one, so pinning does not select a different contract today. It records your
> intent for the first time we ship a second one. Everything below describes how pinning
> will behave then; right now every request gets the same behaviour whatever it pins.
---
## Pinning
When a second version exists, pin it on your key in the dashboard so all your requests
use a stable contract, and use the `API-Version` header to try a newer one first. Until
then the pin is stored but unused; send the header only if you want that date echoed
back.
### The header is checked for shape, not for existence
`API-Version` is validated as a `YYYY-MM-DD` date and nothing more. A **well-formed but
unknown** date is accepted and echoed straight back:
```bash
curl ... -H "API-Version: 1999-01-01"
# Response headers:
# API-Version: 1999-01-01 ← reflected, not honoured
```
So do not treat the response header as confirmation that a version exists. It tells you
what you asked for, not what you got. A malformed value (`v2`, `2026-1-1`) is ignored
entirely and the response carries the resolved default instead, also silently, since an
unparseable header is never a hard failure.
The practical rule: pin the version you read in these docs. Getting it back in the response
header proves the request reached us, not that the date is a real version.
## What counts as a breaking change
New fields, new endpoints, new event types, and new enum values are **additive** and ship
without a version bump, so write your integration to ignore unknown fields. A version bump is
reserved for changes that could break a correctly-written integration (a removed field, a
changed type, a renamed value).
## Request ids
Include the `Request-Id` from a response when you contact support. It lets us find the
exact request in our logs. Every response and every error carries one (the error envelope
also includes `request_id`).
## Object ids are opaque
**Never pattern-match, parse, or validate the shape of an id.** Store the exact string we
return and send it back verbatim. Formats differ per object and are not part of the
versioned contract. A check that a charge id "starts with `txn_`" is a bug waiting for the
first id that does not.
The shapes are genuinely mixed today, which is the point:
| Object | What the API returns |
|---|---|
| Charge | `TXN-` + a UUID (`TXN-8ac4796a-…`). Uppercase, hyphen, **not** `txn_`. |
| Refund | `re_` + the charge-style id (`re_TXN-…`). |
| Payment intent | `pi_` + 32 hex characters. The `client_secret` is that id + `_secret_` + an opaque token. |
| Event | `evt_` + a UUID. |
| Customer | `cus_` + a UUID (`cus_8ac4796a-…`): the customer's `id`, and the customer on payment intents, subscriptions, invoices, customer sessions and customer state. The bare UUID is what charges carry as `customer`, pre-dispute alerts as `customer_id`, and webhook payloads as `customerId`; a saved card's `customer` repeats the id you sent in the path. Customer-id inputs accept either form. `CUS-` is an accepted **lookup slug**, not the id we return. |
| Merchant account | an opaque account id, no prefix. |
| Checkout link, product, blocklist entry, fraud check | bare UUIDs, no prefix. |
Cursors are opaque too, and are **not** ids. See [Pagination](/developers/13-pagination.md). Treat
`next_cursor` as a token to hand back, never as an object reference.
========================================================================
# Going live
You build entirely in test mode, then swap to live keys. Nothing else in your code
changes. This checklist covers the switch.
---
## Before you flip
Read the full list once: [Integration hard rules](/developers/27-integration-hard-rules.md)
(also on the [reference checkout](/demos/checkout-reference.html)).
- [ ] **Charges:** a successful charge returns `status: "succeeded"`; you store the `id`.
- [ ] **Declines:** a test decline (an amount below 1.00, `"amount": 99`; the sandbox has no
decline card) is handled gracefully (you branch on `code`, not crash). Buyer UI shows a
**generic** message, never raw fraud reasons. See [Testing](/developers/15-testing.md) and
[Errors](/developers/10-errors.md).
- [ ] **3-D Secure:** `status: "requires_action"` is handled; you complete the step-up and
resubmit the **same** charge with `three_ds.session_id` added (never client cavv/eci).
See [3-D Secure](/developers/07-3ds.md).
- [ ] **Client IP:** browser + `pk_` charge (buyer peer), server `sk_` charge with the
buyer's `device_session`, **or**, if you proxy `/check`, body `ip` = shopper. A server
charge without a session is never scored on (or blocked by) your egress IP; it just
has no buyer IP. See [Charges → Client IP](/developers/03-charges.md#client-ip-fraud-signal-not-a-body-field).
- [ ] **PAN:** only `ev:` or `wp:v1:` tokens on charges; raw digits never hit the API.
- [ ] **Idempotency:** every charge and refund sends a **stable** [`Idempotency-Key`](/developers/12-idempotency.md)
per order (not a new UUID on every Pay click).
- [ ] **`/check` / decision token:** not pre-approval; token optional but strict if sent;
send `card_bin` + `last4` when you have them. See [Decision token](/developers/26-decision-token.md).
- [ ] **Device signals:** fingerprint / Kount present when CSP allows; empty fails open
(sale still runs). See [Device data](/developers/24-device-data-collection.md).
- [ ] **Keys:** `sk_` server-only; `pk_` + Allowed Domains for browser. Mode on the key, not body.
- [ ] **Refunds:** full and partial refunds work; you verify with `GET /v1/refunds/:id`.
- [ ] **Rate limits:** you back off on `429` honoring `Retry-After`. See [Rate limits](/developers/14-rate-limits.md).
- [ ] **Webhooks:** your endpoint is registered, [verifies signatures](/developers/16-webhooks.md),
returns `2xx` fast, and is idempotent (dedupes on the event/charge id).
- [ ] **Versioning:** your key is [pinned to a version](/developers/20-versioning.md).
---
## The switch
1. **Create live keys** in the dashboard (`pk_live_…` / `sk_live_…` / `rk_live_…`).
Use a **restricted key** (`rk_`) scoped to only what each service needs, not a full
secret key, wherever possible. See [Authentication](/developers/02-authentication.md).
2. **Move secrets to your secret manager.** Never commit a live `sk_`/`rk_` key.
3. **Configure your live webhook endpoint** and store its signing secret.
4. **Set an IP allowlist** on server keys if your infrastructure has stable egress IPs.
5. **Swap the key prefix** in your config from `_test_` to `_live_`. Done.
---
## After launch
- Monitor the `Request-Id` on failures and keep them for support.
- Reconcile periodically with `GET /v1/charges` (and `/v1/events`) so a missed webhook
never leaves your records out of sync.
- Roll keys with the dashboard's roll action. The old key keeps working during a short
grace window so deploys can drain without an outage.
- Need higher rate limits for your live volume? Contact support.
========================================================================
# Libraries and SDKs
## Browser SDK (`@worthypayments/js`)
The browser SDK mounts the Payment Element: a card form served in our iframe that
tokenizes, runs 3-D Secure **when enabled**, and reports the outcome. It is the supported
client library. Apple Pay and Google Pay mount on the **merchant page, directly above the
card iframe** (not inside it) and only when `GET /v1/wallet-config` reports them on.
Load it from the pinned path. Full copy-paste HTML (doctype, CSP, `onSuccess`, handle
methods): [Payment Element](/developers/08b-payment-element.md#2-mount-the-element-browser).
```html
```
Use `/sdk/v1/`. The unversioned `/sdk/worthypayments.umd.js` always serves the newest
build, so every deploy of ours would become an untested change to your live checkout.
Full reference: [Embedded SDK](/developers/08-embedded-sdk.md). Walkthrough:
[Payment Element](/developers/08b-payment-element.md).
### There is no npm package yet
`@worthypayments/js` is not on the public registry. `npm install @worthypayments/js`
returns a 404 today, and so does the older `@tailoredpay` scope. Load the script tag
above.
The bundle sets a single global, `WorthyPayments`. Types for that factory live next
to the UMD:
```
https://app.worthypayments.com/sdk/v1/worthypayments.d.ts
```
That file is the ESM default-export shape (`import WorthyPayments from '…'`). For the
REST surface, generate from OpenAPI below. A published npm package may follow.
### How the hosted bundle is built (maintainers)
Source lives in `packages/sdk-js`. The bytes browsers load are the **tracked** copies
under `apps/merchant-dashboard/public/sdk/` (not gitignored `dist/`). After changing
SDK source:
```bash
npm --prefix packages/sdk-js run build
cp packages/sdk-js/dist/worthypayments.umd.js \
apps/merchant-dashboard/public/sdk/worthypayments.umd.js
cp packages/sdk-js/dist/worthypayments.umd.js \
apps/merchant-dashboard/public/sdk/v1/worthypayments.umd.js
cp packages/sdk-js/dist/index.d.ts \
apps/merchant-dashboard/public/sdk/worthypayments.d.ts
cp packages/sdk-js/dist/index.d.ts \
apps/merchant-dashboard/public/sdk/v1/worthypayments.d.ts
```
While the package is 1.x, keep the floating and `/sdk/v1/` copies identical. A future
2.0 freezes `/sdk/v1/` and adds `/sdk/v2/`. Rebuilding without copying is a known
failure mode: production keeps serving the previous committed UMD.
---
## Server-side
There is no supported server SDK. Call the REST API directly. The surface is small and
conventional, and every endpoint is in the [OpenAPI spec](/developers/openapi.yaml) and the
[Postman collection](/developers/worthypayments.postman_collection.json), so you can generate a
typed client in your language.
> The packages `@worthypayments/sdk` (Node), `worthypayments/sdk` (PHP) and `worthy`
> (Python) in the repo target a legacy internal API, not this `/v1` surface. Do not use
> them.
### Generating a client from OpenAPI
```bash
# Example: a TypeScript client with openapi-typescript
npx openapi-typescript https://app.worthypayments.com/developers/api-reference/openapi.yaml -o worthypayments.d.ts
```
The spec also imports into Postman, Insomnia and Stoplight.
---
## WooCommerce
A drop-in plugin ships the hosted redirect flow with no code. See
[WooCommerce](/developers/09-woocommerce.md).
========================================================================
# Fraud checks
Evaluate a shopper **before** you charge them, and report abuse you have confirmed
yourself. These endpoints are **advisory**: `POST /charges` runs the full fraud engine
again server-side, so a `check` that says `allow` never overrides a decline at charge
time, and a `check` you skip never weakens the real decision.
Use them to fail fast: stop a hopeless checkout before the shopper types a card, or
step up to 3DS on a borderline one.
**Integrator must-dos** (IP when you proxy, `card_bin`+`last4`, token is not pre-approval):
[Integration hard rules](/developers/27-integration-hard-rules.md).
---
## `POST /v1/check`
Requires the `check:read` scope.
```bash
curl https://api.worthypayments.com/v1/check \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"email": "shopper@example.com",
"amount": 4999,
"card_bin": "424242",
"billing_country": "US",
"fingerprint": "wp_fp_9f2c...",
"transaction_type": "one_off"
}'
```
| Field | Type | Req | Notes |
|---|---|---|---|
| `email` | string | yes | Hashed before it leaves the edge; the plaintext is never stored |
| `amount` | integer | | Cents. Send it when you know it; omit for a pre-cart check |
| `card_bin` | string | | First 6 to 8 digits. Never send the full PAN. (`bin` is accepted as a legacy alias; prefer `card_bin`) |
| `last4` | string | | Last 4 of the PAN. **Send with `card_bin`** so the [decision token](/developers/26-decision-token.md) can bind the card; omit either and that binding is not enforced later |
| `billing_country` | string | | ISO-3166 alpha-2 |
| `fingerprint` | string | | Device fingerprint from the [SDK](/developers/08-embedded-sdk.md) |
| `currency` | string | | ISO-4217, lowercase. Defaults to `usd`. Sent with `amount`, it is **bound into the decision token**. A token minted as `usd` cannot be presented for a `eur` charge |
| `ip` | string | yes¹ | **Buyer’s public IP.** ¹Nominally optional (we fall back to the request peer), but the peer is *your server*, so in practice this is required. See [Client IP](#client-ip-effectively-required) |
| `transaction_type` | string | | `PURCHASE` or `SUBSCRIPTION`. Advisory only. It is recorded, not scored |
### Client IP (effectively required)
IP is one of the strongest fraud signals (velocity, blocklist, geo, cascade). Wrong IP
makes the verdict useless or noisy.
**Always set `ip` yourself.** The endpoint does fall back to the request peer, but the
only key that can call it is a secret key on your server, so the peer is your egress
address, not the buyer's. Omitting `ip` scores your data centre.
| Who calls `/v1/check` | What happens |
|---|---|
| **Your backend** (secret key) | The supported path. **Set `ip` to the shopper’s real IP.** |
| Browser / publishable key | **Not possible.** A `pk_` is narrowed server-side to `payments:charge` and scope-`none` endpoints and can never hold `check:read`, so the call returns `403 permission_denied` regardless of what the body contains. Proxy it through your own server. |
Resolve the shopper IP the same way a well-configured proxy does: the hop **your**
load balancer or CDN appended (not the left-most `X-Forwarded-For` entry, which the
client can forge). Pass a single public IPv4/IPv6 string, e.g. `"203.0.113.42"`.
> **`POST /v1/charges` does not accept body `ip`.** Charge IP is never read from the body,
> so it cannot be spoofed by a caller. A charge built on your server records your egress
> address as your server's, never scored or blocklisted as a buyer's; send the buyer's
> [`device_session`](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
> to score it on the buyer's IP. The [Payment Element](/developers/08b-payment-element.md) does not
> need one: the browser confirms directly, so the peer is the buyer. Either way, use
> `/check` with an explicit `ip` for the advisory step.
> Details: [Charges → Client IP](/developers/03-charges.md#client-ip-fraud-signal-not-a-body-field).
```json
{
"object": "fraud_check",
"status": "clean",
"action": "allow",
"requires_3ds": false,
"reasons": [],
"risk_score": 12,
"check_id": "8300054a-bc4d-4746-861f-63804710d69d",
"risk_decision_token": "cmR0MXxt….9f8e7d…"
}
```
| Field | Notes |
|---|---|
| `status` | `clean`, `flagged`, or `blocked` |
| `action` | `allow`, `review`, or `deny`. What we recommend you do |
| `requires_3ds` | Advisory: if `true`, expect a possible 3-D Secure challenge on the later charge. To run 3DS on that charge whatever your rules decide, send `three_ds.request` (`any`, `challenge` or `challenge_mandated`). When the charge returns `requires_action`, complete the challenge and resubmit with `three_ds.session_id` (see [3-D Secure](/developers/07-3ds.md)). |
| `reasons` | Machine-readable reason codes behind the verdict |
| `risk_score` | 0 to 100, higher is riskier |
| `check_id` | Correlates this evaluation with the later charge in your dashboard. **An empty string means the engine was never reached** (see the fail-open note below) |
| `risk_decision_token` | Signed receipt for this evaluation. Echo it on the charge to tie the two together. See [Risk decision token](/developers/26-decision-token.md). Absent when token signing is not configured |
> **This endpoint fails open, and you can detect it.** If the fraud engine is unreachable
> the call still returns `201` with `status: "clean"`, `action: "allow"` and `risk_score:
> 0`. An outage must not stop a merchant taking payments. The tell is **`check_id: ""`**
> (and no `risk_decision_token`): a real evaluation always carries an id. Treat that
> response as *unscreened*, not as approved. The charge itself is unaffected. It runs its
> own fraud pass server-side either way.
> **This endpoint is advisory.** `POST /charges` runs the full fraud engine again on the
> data the charge actually carries. Skipping `/check` never bypasses screening, and
> passing it never pre-approves a charge. What the token adds is provenance: a charge that
> drifts from the evaluation it claims (different amount, card or device) is *rejected*.
### Linked-account blocks
`BLOCKED_BYPASS_LINK` means an active device or IP link points to a customer who is
currently blocked in the same merchant account and environment. It returns
`status: "blocked"`, `action: "deny"`, and `risk_score: 99`. The link reason is a
fixed code, never a customer or anchor identifier. This check runs after the inline
Cascade check and before velocity; an earlier allowlist match or disabled fraud
screening still wins.
`/check` has no customer or card, so a buyer allowlisted only by customer or card
can still be denied by `/check` on a device/IP link that the charge then approves.
The Cascade **sync-block** switch controls both device and IP link checks. The
**device** switch additionally controls device links. The inline-IP switch does
not control IP links. Missing settings and NULL device/sync-block values keep link
protection on; stored false disables the corresponding protection.
Blocked attempts still record link evidence while these switches are off. Device
links last 30 days and IP links 7 days; re-enabling protection re-arms links that
have not expired and still point to a blocked customer.
## `POST /v1/check/recheck`
Identical request and response. Call it once the final amount is known. A cart that
grew after the first check is exactly the case a single check misses.
---
## `POST /v1/report`
Report abuse you have confirmed yourself, so it counts against future attempts.
Requires the `report:write` scope.
```bash
curl https://api.worthypayments.com/v1/report \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{ "email": "abuser@example.com", "action": "block", "reason": "confirmed chargeback fraud" }'
```
`action` is `block` or `flag`. `block` denies future attempts outright; `flag` marks
them for review without stopping them.
---
## `GET /v1/blocklist`
Read back what is currently blocked for your account. Requires `blocklist:read`.
Paginates like every other list. See [Pagination](/developers/13-pagination.md).
```json
{
"object": "list",
"data": [
{
"object": "blocklist_entry",
"id": "d2fb8af6-d015-4a27-ac59-f43ec314ae64",
"type": "email",
"value": "abuser@example.com",
"reason": "confirmed chargeback fraud",
"expires_at": null,
"created_at": "2026-07-27T10:00:00Z",
"is_global": false
}
],
"has_more": false
}
```
`is_global: true` marks a platform-wide entry. You cannot remove those, and they apply
across every merchant.
---
## 3DS and fraud controls from your own tools
If you keep your own admin tooling, these endpoints let it read and drive the same 3DS
and fraud levers as the dashboard, so you do not maintain two rule sets by hand.
| Lever | Scope of effect | API |
|---|---|---|
| Ask for 3DS on one payment | that payment | `three_ds.request` on `POST /v1/charges` and `POST /v1/payment_intents` |
| The customer's **Require 3DS** flag | every later card charge for that customer | `GET`, `POST`, `DELETE /v1/customers/{id}/three_ds` |
| The risk-score 3DS step-up (score 70–95) | every card charge in this mode | `GET`, `POST /v1/fraud/three_ds_policy` |
| Fraud rules | every charge they match | `GET /v1/fraud/rules` (read-only) |
| Payment Workflow routes, incl. **Attempt 3DS** | every charge the route wins | `GET /v1/payment_workflow/routes` (read-only) |
**A rule outranks a request.** You can always *start* 3DS — on one payment with
`three_ds.request`, or for a customer with the flag — but nothing you send can switch it
*off* when our side asks for it. If a fraud rule, a detector, the customer's flag or the
winning route asks for 3DS, the charge is stepped up and challenged even when the request
asked for something softer. Clearing the customer's flag lifts only the flag; the rules
and routes keep applying.
### The risk-score 3DS step-up
Every card charge gets a fraud risk score from 0 to 100. By default a charge scoring 70
to 95 is stepped up to 3DS, whatever your own rules decide. If you choose 3DS yourself
with `three_ds.request`, turn that off so your rules are the only trigger:
```bash
curl https://api.worthypayments.com/v1/fraud/three_ds_policy \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{ "risk_step_up": { "enabled": false } }'
```
```json
{
"object": "three_ds_policy",
"risk_step_up": { "enabled": false, "min_score": 70, "max_score": 95 },
"livemode": false
}
```
- **The body sets the whole band.** To narrow it instead, send
`{ "risk_step_up": { "enabled": true, "min_score": 85 } }`. A bound you leave out goes
back to its default (70 or 95).
- **It applies from the next charge.** TEST and LIVE keep separate settings; your key
decides which one you change. `GET /v1/fraud/three_ds_policy` reads it.
- **It lifts only the score band.** A charge is still stepped up when a fraud rule asks
for it, the customer is marked Require 3DS, or the route has Attempt 3DS on, and a score
above your decline threshold still declines.
- **Scopes:** reading needs `fraud:read`; changing needs `fraud:write`, which only secret
and restricted keys can hold.
### The customer's Require 3DS flag
```bash
curl https://api.worthypayments.com/v1/customers/cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10/three_ds \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{ "reason": "Chargeback on file" }'
```
```json
{
"object": "customer_three_ds",
"customer": "cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10",
"require_3ds": true,
"livemode": false
}
```
`POST` turns it on, `DELETE` clears it and `GET` reads it. It is the flag the dashboard
shows under **Restrictions** on the customer's page, so a change made in either place is
visible in the other. Both writes are safe to repeat and answer the flag as read back
after the write. `DELETE` clears the flag whoever set it, a fraud detector included —
exactly what **Turn off** does in the dashboard. A change made through the API is recorded
against your key, so it can be told apart from a dashboard click. TEST and LIVE keys
see separate flags. An unknown customer id is a `404`.
### Reading your rules and routes
`GET /v1/fraud/rules` returns your fraud rules in evaluation order with their
`conditions`, `action` and `enabled` state; `requires_3ds` marks a rule that steps the
charge up. `GET /v1/payment_workflow/routes` returns the Payment Workflow canvas route by
route, in the order it is evaluated, with `require_3ds` for the route's **Attempt 3DS**
toggle. Each route also keeps one fraud rule in step with it; that rule's `route` field
names the route.
Both are **read-only**. Creating, editing or disabling rules and routes stays in the
dashboard: a key that could rewrite them could also switch your fraud protection off or
send money to a different processor, and opening that up needs its own decision. Tell
us if your integration needs it.
---
## Scopes
These endpoints are **server-side only**. A publishable (`pk_`) key can never reach
them: the authority is the key, not the request body, so no browser call can grant
itself `check:read`, `report:write`, `fraud:read` or `fraud:write`. `fraud:read` covers
the three reads above; `fraud:write` covers setting and clearing the customer flag.
Related: [Authentication](/developers/02-authentication.md) · [Errors](/developers/10-errors.md) ·
[3-D Secure](/developers/07-3ds.md)
========================================================================
# 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](/developers/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](/developers/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`](/developers/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](/developers/08b-payment-element.md) · [Embedded SDK](/developers/08-embedded-sdk.md) ·
[Charges](/developers/03-charges.md) · [Fraud checks](/developers/23-fraud-checks.md)
========================================================================
# Integration Modes
Pick how much of the payment you want us to run. The mode lives on your API key,
so it is decided once at key creation. No request field can change it, not for you and
not for anyone who gets hold of your key.
Before you ship: [Integration hard rules](/developers/27-integration-hard-rules.md) (IP, 3DS,
idempotency, token, device signals).
| | **Atomic** | **Fraud only** | **Payment only** |
|---|---|---|---|
| `POST /v1/check` | yes | yes | no (`mode_not_allowed`) |
| `POST /v1/charges` | yes | no (`mode_not_allowed`) | yes |
| Who moves the money | us | your own acquirer | us |
| Fraud engine at charge time | full | n/a | full |
| Decision token issued | yes | yes | — |
| Decision token accepted | yes | n/a | yes |
| Typical integrator | most | already has a PSP | rare |
---
## Atomic: one call
Send the buyer's details once; we screen, decide, step up to 3DS if the risk
calls for it, and charge.
```bash
curl -X POST https://api.worthypayments.com/v1/charges \
-H "X-Api-Key: sk_live_…" \
-H "Idempotency-Key: order_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"card": { "number": "ev:…", "cvv": "ev:…", "exp_month": "12", "exp_year": "2030" },
"customer": { "email": "buyer@example.com" },
"device": { "fingerprint": "fp_9a1c2b" }
}'
```
Three outcomes. Branch your UI **only** on these; full JSON shapes are in
[Charges](/developers/03-charges.md#responses).
| `status` / HTTP | What to do |
|---|---|
| `succeeded` | Done. Show the receipt. |
| `requires_action` | Run the 3DS challenge, then resubmit the same charge with `three_ds.session_id` added and nothing else changed. See [3-D Secure](/developers/07-3ds.md). The Payment Element drives this itself (the challenge opens inside the Element's frame, which grows to fit it), so a browser integration never sees this status. |
| `{ "error": … }` (HTTP 402) | Declined. Show a generic decline, never the reason. |
| 400 / other error | Fix the request (missing `Idempotency-Key`, bad token, validation). See [Errors](/developers/10-errors.md). |
This is the right default. One request, one decision, nothing to keep in sync.
**Client IP on charge:** not a body field. A charge built on your server comes from your
egress address, which we record as your server's and never score or blocklist, so it cannot
get your backend blocked. To score the charge on the buyer's IP and browser, send the
[`device_session`](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
the buyer's browser created. The [Payment Element](/developers/08b-payment-element.md) confirms from
the browser, so there the peer is the buyer. Details:
[Charges → Client IP](/developers/03-charges.md#client-ip-fraud-signal-not-a-body-field).
---
## Two-step: you own the decision UI
Same Atomic key, two calls. Use this when you want to show your own interstitial
before charging: your own decline page, your own "verifying your card" screen,
your own review queue.
**1. Evaluate**
```bash
curl -X POST https://api.worthypayments.com/v1/check \
-H "X-Api-Key: sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"email": "buyer@example.com",
"fingerprint": "fp_9a1c2b",
"card_bin": "424242",
"last4": "4242",
"amount": 4999,
"currency": "usd",
"ip": "203.0.113.42"
}'
```
> **`ip` is the shopper’s address, not yours.** When this call is made from your server,
> omit it and we score your egress IP instead, which is wrong. See [Fraud checks → Client IP](/developers/23-fraud-checks.md#client-ip-effectively-required).
```json
{
"object": "fraud_check",
"status": "clean",
"action": "allow",
"requires_3ds": true,
"reasons": [],
"risk_score": 12,
"check_id": "2a5aed3a-…",
"risk_decision_token": "cmR0MXxtYV8x….9f8e7d…"
}
```
**2. Decide**
| `action` | Meaning |
|---|---|
| `deny` | Stop. Do not charge. |
| `review` | Charge, but flag it for your ops team. |
| `allow` | Proceed. If `requires_3ds` is true, expect a challenge. |
**3. Charge**, echoing the token:
```jsonc
{
"amount": 4999,
"currency": "usd",
"card": { "…": "…" },
"device": { "fingerprint": "fp_9a1c2b" },
"risk_decision_token": "cmR0MXxtYV8x….9f8e7d…" // optional, strictly checked
}
```
See [Risk Decision Token](/developers/26-decision-token.md) for what the token binds and
which errors it can produce.
> **The charge is not "pre-approved" by the check.** The full engine runs again
> at charge time, on the data the charge actually carries. That is deliberate:
> blocklists, velocity counters and rules change in the minutes between the two
> calls, and the charge is the moment the money moves.
---
## Fraud only: decision without the money
For merchants who already have an acquirer and want our screening. `/v1/check`
and `/v1/check/recheck` work; `/v1/charges` returns `403 mode_not_allowed` on
every request, so a leaked key cannot move money.
The token is still issued. It is your audit artefact: proof of what we said and
when, for a chargeback file.
---
## Payment only: charge without the advisory surface
`/v1/charges` works, `/v1/check` returns `403`. Note this does **not** mean
"skip fraud". The engine runs on every charge in every mode. It means you do not
get the standalone pre-checkout evaluation.
---
## Choosing
Start with **Atomic**. Move to two-step when you need control over what the buyer
sees between decision and payment. Choose **Fraud only** when the money already
goes somewhere else.
Ask support to change your mode; it is a property of the key, so in practice you
are issued a new one.
========================================================================
# 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](/developers/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](/developers/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.
========================================================================
# Card tokenization (`wp:`)
A card number must never reach WorthyPayments in the clear. `wp:` is a token format that
makes that true: the PAN, expiry and CVV are encrypted into one opaque string, and only
the gateway service can open it.
```
wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…
```
You send that string as `card.wp_token` on a charge.
> **If you use the Payment Element, you do not need this page.** The element tokenizes
> inside the iframe we serve and confirms the payment itself. Nothing on this page is
> something you would call. See [Payment Element](/developers/08b-payment-element.md).
This page is for building a charge **outside** the browser element: a server that calls
`POST /v1/charges` directly, a native mobile app, or your own encryptor in any language
with an HPKE library.
> **Live since 20 August 2026.** The `wp:` rail is enabled on the live platform: key
> version `1` is provisioned, `GET /v1/wp-config` serves the public half, and every step
> on this page runs against production. Confirm it for yourself before you build. One
> request, and the same check a year from now:
> ```bash
> curl -s -o /dev/null -w '%{http_code}\n' https://api.worthypayments.com/v1/wp-config \
> -H "x-api-key: $WP_API_KEY"
> ```
>`200` is the rail as described here. A `503 wp_config_unavailable` would mean the key is
>not currently served and nothing on this page can run. It is not a transient error to
>retry past, so tell us if you ever see one. The rail is opt-in on the client: the SDK
>tokenizes over `ev:` unless you ask for `tokenize(card, "wp")` (or `"both"`). If you
>would rather not build an encryptor at all, the [Payment
>Element](/developers/08b-payment-element.md) tokenizes inside the iframe we serve and needs nothing
>from this page.
---
## Two rails, and which one you want
A card charge carries its card on one of two rails. They are not interchangeable.
| Rail | What you send | Choose it when |
|---|---|---|
| `ev:` | Separate Evervault tokens in `card.number` and `card.cvv` | The payment may step up to 3-D Secure. 3DS consumes this rail. |
| `wp:` | One `wp:v1:` envelope in `card.wp_token` | You are building a 2D charge and want no third-party hop. |
> **Warning:** `wp:` alone cannot run 3-D Secure. Opening a 3DS session requires an `ev:`
> token: the challenge provider cannot read our envelope. On a card-not-present sale that
> may step up, mint `ev:` as well and send whichever the moment needs. See
> [3-D Secure](/developers/07-3ds.md).
Because PAN, expiry and CVV are sealed together, **there is no `wp:` CVV**. A standalone
`wp:` value on `card.cvv` is refused: nothing can produce one and nothing can open one.
> **Handling a PAN puts you in a wider PCI assessment.** Our iframe keeps you on SAQ A
> (~30 requirements); a card entered into your own page or passing through your server is
> SAQ A-EP (~139) or broader. `wp:` reduces where the PAN travels, it does not remove the
> obligation. Choose this rail because you cannot use the element, not to avoid scope.
Rail-by-rail parameters, envelope contents and failure modes:
[`wp:` rail reference](/developers/29-sdk-reference.md).
---
## What the token is
| Part | Value |
|------|-------|
| Prefix | `wp:`, constant |
| Version | `v1`, the format version. A new crypto suite would be `v2`. |
| Body | Standard base64 (RFC 4648 §4, the `+/=` alphabet, **not** base64url) |
Grammar, identical in every implementation:
```
^wp:v1:[A-Za-z0-9+/]+={0,2}$
```
**Maximum length 512 characters.** A typical card token is 180 to 260. Anything longer is
rejected without an attempted decrypt.
The token is **single use** and **expires in 600 seconds (10 minutes)**. Both limits are
hard. `GET /v1/wp-config` returns `token_ttl_seconds: 600`; the gateway enforces that
same value. Mint as late as you can, immediately before the charge. A token is also bound
to the merchant account it was minted for: you cannot mint against one account and charge
another.
> **Every sealing produces a different token, even for the same card.** The encryption is
> non-deterministic by design. Do not cache tokens, deduplicate on them, or use one as a
> card identifier. It identifies nothing once spent.
---
## What goes in it
This is the JSON that gets encrypted. If you write your own encryptor, you build this
object yourself, and the field set is exact.
| Field | Type | Required | Rule |
|-------|------|:---:|------|
| `pan` | string | Yes | Digits only, 13 to 19 of them, Luhn-valid |
| `exp` | string | Yes | `MM/YY`, exactly this format |
| `cvv` | string | No | 3 or 4 digits. **If absent, omit the field.** Do not send `""`. |
| `iat` | number | Yes | Issued-at, **unix seconds** (not milliseconds) |
| `jti` | string | Yes | 32 hex characters, 128 random bits |
| `aud` | string | Yes | The target `merchant_account_id` |
| `kid` | number | Yes | Key version, from `/wp-config` |
**No extra fields.** An unrecognized key makes the whole token `wp_token_malformed`.
> `bin` and `last4` are **not** inside the envelope. Read them off the plain PAN before
> you encrypt, and send them as ordinary fields on the charge body. They are not
> recoverable from the token.
---
## Test environment
Test mode is selected by your key: a `*_test_*` key is test mode. Same engine, same
tokenization, no live acquirer. See [Testing](/developers/15-testing.md) for the wider test-mode
guide.
### Test cards
⚠️ **The old list here was Stripe's, and NMI's sandbox refuses all of it.** A TEST charge
is posted to NMI's own sandbox now, which accepts **seven** card numbers and answers
everything else with `Bad Request. Please use a valid test card or test ACH account from
the documentation.` `4242 4242 4242 4242`, the `4000 …` decline family and the rest buy
nothing on either rail — including this one, because a `wp:` envelope carries a real PAN
to the same gateway.
The seven numbers, the AVS and CVV triggers, the amount that produces a decline, the
duplicate-transaction window and the sandbox's 3-D Secure cards are one table, in one
place: [Testing → Test cards](/developers/15-testing.md#test-cards-verified-2026-09-06-against-nmis-sandbox).
Keeping a second copy here is what sent an integrator down a broken test card before.
CVV `123` works for all of them (`1234` for Amex), and any future expiry does.
### First charge from a terminal
You do not need a browser to prove the charge path works. `POST /v1/test/tokens` mints
tokens for a test card:
```bash
curl https://api.worthypayments.com/v1/test/tokens \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"number":"4111111111111111"}'
```
```json
{
"object": "test_token",
"card": { "number": "ev:test_token_pan_1111", "cvv": "ev:test_token_cvc_xxx", "last4": "1111" }
}
```
Then charge them:
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 2000,
"currency": "usd",
"card": {
"number": "ev:test_token_pan_1111",
"cvv": "ev:test_token_cvc_xxx",
"exp_month": "12",
"exp_year": "2030",
"last4": "1111"
}
}'
# → { "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "succeeded", … }
```
> **Two fields, one label.** `number` takes a full test PAN; `card` takes a brand alias
> (`visa`, `mastercard`, `mc`, `amex`, `discover`, `jcb`, `diners`, `maestro`), and a brand
> word in `number` is read as the same alias. Either way the token is labelled with the
> card's **last four digits** — so `visa`, `mastercard`, `mc` and `amex` all mint
> `ev:test_token_pan_1111` and charge as Visa, because the sandbox numbers for those three
> brands end in the same four digits. A live key gets `400 test_mode_only`.
> The response needs no renaming: `card.cvv` comes back named the way both
> `POST /charges` and the Element confirm read it, so the whole `card` object goes
> straight through. It used to be emitted as `cvc`, which meant copying it verbatim
> silently dropped the CVV.
> This endpoint mints **`ev:`** tokens. It is the fastest way to exercise the charge,
> idempotency and webhook paths from a script, but it does not exercise the `wp:` path.
> For that, encrypt directly as below.
---
## Building a `wp:` token yourself
If you are writing a server-side integration, a native app, or an in-house encryptor in
Python, PHP, Java, Go, or anything else with an HPKE library, the format is fully
specified. Four steps.
### 1. Fetch the public key
```bash
curl https://api.worthypayments.com/v1/wp-config -H "x-api-key: pk_test_xxx"
```
```json
{
"kid": 1,
"public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
"alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
"info": "worthy-card-v1",
"token_ttl_seconds": 600
}
```
`public_key` is a 65-byte uncompressed P-256 point in standard base64. `alg` is a fixed
string: if you do not recognize it, fail rather than guess. Take `info` from this response
and pass it into HPKE, rather than hardcoding your own copy.
Cache this for up to an hour (`Cache-Control: public, max-age=3600`), then refetch.
**Never hardcode the response.** See [Key rotation](/developers/31-key-rotation.md).
The endpoint accepts a publishable key. The response is the public half of a platform-wide
keypair, not a per-merchant secret; what binds a token to your account is the `aud` claim
you put in the envelope.
### 2. Build the envelope
```json
{
"pan": "4111111111111111",
"exp": "12/30",
"cvv": "123",
"iat": 1754870400,
"jti": "9f86d081884c7d659a2feaa0c55ad015",
"aud": "mer_01H8XYZ…",
"kid": 1
}
```
`iat` is unix **seconds**. `jti` is 32 hex characters from a cryptographic RNG, a fresh one
per token, never reused. `aud` is your merchant account id, which `GET /v1/ping` returns
as `merchant_account_id` for the key you are using. `kid` is copied from `/wp-config`. Omit
`cvv` entirely when you do not have one.
### 3. Encrypt with HPKE
```
HPKE (RFC 9180), base mode
KEM : DHKEM(P-256, HKDF-SHA256) 0x0010
KDF : HKDF-SHA256 0x0001
AEAD : AES-256-GCM 0x0002
info : "worthy-card-v1" ASCII, exactly this string
```
Seal the JSON to the public key. Concatenate the 65-byte encapsulated key with the
ciphertext (`enc || ciphertext`) and base64-encode the result with the standard alphabet.
> **The `info` string must match byte for byte.** If it differs, decryption fails on our
> side with no explanation of why: you get `wp_token_malformed` and nothing more specific.
> That is deliberate. A distinguishable crypto error is a card-validation oracle.
A minimal Node implementation with `@hpke/core`:
```js
import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256, Aes256Gcm } from '@hpke/core';
const suite = new CipherSuite({
kem: new DhkemP256HkdfSha256(),
kdf: new HkdfSha256(),
aead: new Aes256Gcm(),
});
const raw = Uint8Array.from(Buffer.from(config.public_key, 'base64'));
const pk = await suite.kem.importKey('raw', raw, true);
const sender = await suite.createSenderContext({
recipientPublicKey: pk,
info: new TextEncoder().encode(config.info),
});
const envelope = JSON.stringify({ pan, exp, cvv, iat, jti, aud, kid: config.kid });
const ct = await sender.seal(new TextEncoder().encode(envelope));
const out = new Uint8Array(sender.enc.byteLength + ct.byteLength);
out.set(new Uint8Array(sender.enc), 0);
out.set(new Uint8Array(ct), sender.enc.byteLength);
const token = 'wp:v1:' + Buffer.from(out).toString('base64');
```
### 4. Charge
The envelope goes in **`card.wp_token`**. Leave `card.number` and `card.cvv` out
entirely: the CVV is already sealed inside, and a `wp:` value on `card.cvv` is refused.
```bash
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 2000,
"currency": "usd",
"card": {
"wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…",
"exp_month": "12",
"exp_year": "2030",
"bin": "411111",
"last4": "1111"
}
}'
```
Read `bin` and `last4` off the plain PAN *before* you encrypt. They are not recoverable
from the token, and both fraud scoring and routing read them.
Confirming a payment intent takes the same envelope as a top-level `wp_token` field rather
than a `card` object. See [Charges](/developers/03-charges.md).
---
## Rules that are enforced, not advisory
- **A raw PAN is rejected at the edge.** Sending `"number": "4242424242424242"` gets you
`400 raw_pan_rejected`. There is no configuration that relaxes this.
- **One token, one charge.** A spent `jti` is `wp_token_replayed`. A failed charge still
spends the token, so a retry has to re-tokenize.
- **Ten minutes.** Past that, `wp_token_expired`. The clock on the encrypting host sets
`iat`, so run NTP.
- **Publishable keys belong in the browser; secret keys never do.** A `pk_` key can create
charges with a freshly tokenized card, which is why domain locking matters. It cannot
charge a saved card, capture or void. See
[Integration hard rules](/developers/27-integration-hard-rules.md).
Full list with causes and fixes: [Error codes](/developers/30-error-codes.md).
========================================================================
# `wp:` rail reference
The reference for the `wp:` tokenization rail: what the envelope contains, what the rules
on it are, and how to produce one without our browser SDK.
> **If you use the Payment Element, skip this page.** The element seals the card inside
> the iframe we serve and confirms the payment itself. There is no tokenization step you
> write. See [Payment Element](/developers/08b-payment-element.md).
This page is for building a charge outside the element: a server calling
`POST /v1/charges`, a native app, or your own encryptor. The end-to-end walkthrough with
runnable code is [Card tokenization](/developers/28-card-tokenization.md); this page is the contract
behind it.
---
## The rail in one line
One HPKE envelope, containing PAN + expiry + CVV, sent as `card.wp_token`.
```jsonc
{
"amount": 2000,
"currency": "usd",
"card": {
"wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…",
"exp_month": "12",
"exp_year": "2030",
"bin": "424242",
"last4": "4242"
}
}
```
`exp_month`, `exp_year`, `bin` and `last4` ride outside the token in the clear. The expiry
is duplicated inside the envelope, which is intentional: the sealed copy is the one the
gateway trusts, and the outer copy is what routing and display read without decrypting.
**There is no `wp:` CVV field.** The CVV is inside the envelope. A `wp:` value on
`card.cvv` is rejected: the SDK cannot produce one and no service can open one.
On a payment intent, the envelope is a top-level `wp_token` field on confirm rather than a
member of `card`. Pass exactly one instrument; two is refused, not resolved.
---
## Envelope contents
The plaintext that gets sealed. The field set is closed: an unrecognized key fails the
whole token as `wp_token_malformed`.
| Field | Type | Required | Rule |
|-------|------|:---:|------|
| `pan` | string | Yes | Digits only, 13 to 19, Luhn-valid. Strip spaces yourself. |
| `exp` | string | Yes | Exactly `MM/YY`. Zero-pad the month; take the last two digits of a 4-digit year. |
| `cvv` | string | No | 3 or 4 digits. **Omit the key when absent.** An empty string counts as present and fails. |
| `iat` | number | Yes | Issued-at in unix **seconds**. `Date.now()` is milliseconds; a raw value reads as the year 57000 and fails the TTL check. |
| `jti` | string | Yes | 32 lowercase hex characters, 128 bits from a CSPRNG. Fresh per token. |
| `aud` | string | Yes | The `merchant_account_id` this token may be spent against. |
| `kid` | number | Yes | Key version, copied from the same `/wp-config` response as `public_key`. |
`bin` and `last4` are deliberately not in here. Read them from the plain PAN before
encryption and put them on the charge body.
Resolving `aud`: `GET /v1/ping` returns `merchant_account_id` for the key you present.
Derive it rather than accepting it as caller input. A wrong `aud` is only discovered after
the card has already been encrypted, and it surfaces as an opaque
`wp_token_audience_mismatch`.
---
## The token on the wire
| Part | Value |
|------|-------|
| Prefix | `wp:v1:` |
| Body | `base64std(enc \|\| ciphertext)`, where `enc` is the 65-byte P-256 encapsulated key |
| Grammar | `^wp:v1:[A-Za-z0-9+/]+={0,2}$` |
| Max length | 512 characters. A card token is normally 180 to 260. |
Standard base64, the `+/=` alphabet. A `-` or `_` in your token means you reached for
base64url, and the grammar check rejects it before anything tries to decrypt.
Cipher suite, fixed:
```
HPKE (RFC 9180), base mode
KEM : DHKEM(P-256, HKDF-SHA256) 0x0010
KDF : HKDF-SHA256 0x0001
AEAD : AES-256-GCM 0x0002
info : "worthy-card-v1" ASCII, byte for byte
```
`info` is part of the AEAD binding. One character of drift and every decrypt fails with
`wp_token_malformed` and no further detail.
---
## `GET /v1/wp-config`
The public key and its version. Authenticates with a publishable or a secret key.
```json
{
"kid": 1,
"public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
"alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
"info": "worthy-card-v1",
"token_ttl_seconds": 600
}
```
| Field | Notes |
|-------|-------|
| `kid` | Key version. Goes into the envelope. |
| `public_key` | 65-byte uncompressed P-256 point, standard base64. |
| `alg` | The suite string. Check it and refuse anything you do not implement. |
| `info` | The HPKE `info`. Take it from here rather than hardcoding a copy. |
| `token_ttl_seconds` | `600` (10 minutes). The server expires the envelope at this value; you cannot extend it. Not 900 / 15 minutes. |
The keypair is platform-wide, not per merchant. Publishing the public half is the design:
anyone can mint a well-formed envelope, and what stops them charging your account is the
`aud` binding, the single-use `jti` and the velocity controls behind it.
Cache the response for at most an hour (`Cache-Control: public, max-age=3600`), then
refetch. Take `kid`, `public_key` and `info` from the same response, together. **Never
hardcode any of it.** A pinned value breaks every card at once at the next rotation, and
the failure looks like a corrupt-token bug. See [Key rotation](/developers/31-key-rotation.md).
A `503 wp_config_unavailable` means the key is not provisioned. It is retriable, and you
must not cache it.
---
## Two rules that break code written against `ev:`
**Single use.** One token, one charge attempt. A charge that failed still spent the token,
so a retry has to seal a fresh one. Sending a spent token is `wp_token_replayed`, which is
not a second attempt at the card.
**600 seconds (10 minutes).** Wall-clock from `iat`, not per hop. If tokens sit in a queue
on your side before the charge fires, budget for it. A job that retries an hour later needs
a new token, which means going back to the card.
```
// Wrong: the card is collected, then the order is assembled, then the charge fires.
seal() -> queue -> enrich -> charge // wp_token_expired somewhere in the middle
// Right: seal immediately before the call that spends it.
queue -> enrich -> seal() -> charge
```
`ev:` tokens have neither limit. That is exactly why code moved from `ev:` to `wp:` breaks
at the retry path and at slow checkouts rather than on the happy path. See
[Migrating from `ev:`](/developers/32-migration-from-ev.md).
Never persist a raw PAN anywhere so you can re-seal later. If you cannot re-seal, re-collect
the card.
---
## Failure modes
Failures split by where they happen.
**Before the charge**, while you are producing the token. Your own code owns these, and
none of them reach us:
| Condition | What to do |
|---|---|
| PAN fails Luhn or is not 13 to 19 digits | Validate before sealing; a bad PAN wastes a round trip |
| `alg` from `/wp-config` is not one you implement | Fail loudly at config-fetch time. Encrypting with the wrong suite produces a token nobody can open. |
| `public_key` or `kid` absent | Treat as unavailable and retry; do not seal against a partial config |
| Sealed token exceeds 512 characters | You put something oversized in `aud`, or added a field |
If you are encrypting in a browser context of your own, Web Crypto requires a secure
context: HTTPS, with `localhost` exempt. There is no safe fallback to sending the raw card,
and the API rejects raw PANs regardless.
**At the charge**, from `POST /v1/charges`. Every one of these is a fault in the request
rather than a problem with the card:
| Code | HTTP | Meaning |
|---|:---:|---|
| `wp_token_expired` | 400 | Older than 600 seconds (10 minutes) |
| `wp_token_replayed` | 400 | This `jti` was already spent |
| `wp_token_malformed` | 400 | Any crypto-level failure, reported uniformly |
| `wp_token_audience_mismatch` | 400 | Minted for a different merchant account |
| `wp_token_too_large` | 400 | Over 512 characters, rejected without decrypting |
| `raw_pan_rejected` | 400 | A plain card number reached the API |
| `wp_replay_store_unavailable` | 503 | Our outage. The only one where the identical request can succeed. |
Causes, fixes and retry rules for each: [Error codes](/developers/30-error-codes.md).
---
## Where the rail is decided
The grammar of the value chooses which decryptor opens it, so the field you use is not
cosmetic:
| Field | Accepts | Opened by |
|---|---|---|
| `card.wp_token` | `wp:v1:` only | Our gateway service |
| `card.number` | `ev:` only | Evervault |
| `card.cvv` | `ev:` only | Evervault |
Putting a `wp:` envelope on `card.number` does not fall back gracefully. The two grammars
are allowlisted per field and a mismatch is refused, which is the behavior you want: the
alternative would be ciphertext forwarded to an acquirer as if it were a card number.
3-D Secure runs on the `ev:` rail. If a payment may step up, mint `ev:` tokens for it. See
[3-D Secure](/developers/07-3ds.md).
========================================================================
# Tokenization error codes
Every `wp:` failure returns one of seven codes. This page says what each one means, why it
happened, and what to change.
```json
{
"error": {
"type": "invalid_request_error",
"code": "wp_token_expired",
"message": "The card token has expired. Tokenize the card again.",
"param": "payment_method"
}
}
```
> **If you use the Payment Element, none of these are yours to handle.** The element
> tokenizes internally and manages its own retries. See
> [Payment Element](/developers/08b-payment-element.md). This page is for integrations that build a
> `wp:` token themselves and call `POST /v1/charges`.
For the wider error envelope (declines, auth failures, rate limits) see
[Errors](/developers/10-errors.md).
---
## The seven codes
| Code | HTTP | Retry the identical request? | One-line fix |
|------|:---:|:---:|-------------|
| [`wp_token_expired`](#wp_token_expired) | 400 | no | Seal a new token |
| [`wp_token_replayed`](#wp_token_replayed) | 400 | no | Seal a new token. One token, one charge. |
| [`wp_token_malformed`](#wp_token_malformed) | 400 | no | Refetch `/wp-config` and seal again, once. Then check your encryptor. |
| [`wp_token_audience_mismatch`](#wp_token_audience_mismatch) | 400 | no | Mint and charge against the same account and mode |
| [`wp_token_too_large`](#wp_token_too_large) | 400 | no | Do not add fields to the envelope |
| [`raw_pan_rejected`](#raw_pan_rejected) | 400 | no | Never send a raw PAN. Tokenize first. |
| [`wp_replay_store_unavailable`](#wp_replay_store_unavailable) | 503 | yes | Our outage. Retry with backoff. |
Six are `type: "invalid_request_error"`. `wp_replay_store_unavailable` is `api_error` and
is the only one where sending the exact same bytes again can work.
"Retry" in that column means *the identical request can succeed*. A **no** does not mean the
sale is lost. For an expired or replayed token, sealing a fresh one and charging again is
the correct recovery, and it should happen automatically. What must not happen is a loop
that resends the same token.
---
## Retry policy in one table
| Situation | Action |
|---|---|
| `429`, `5xx`, network failure | Retry with backoff |
| `wp_token_expired`, `wp_token_replayed` | Re-tokenize, then retry once |
| `wp_token_malformed` | Refetch `/wp-config`, re-tokenize, retry once |
| `wp_token_too_large`, `wp_token_audience_mismatch`, `raw_pan_rejected` | Never retry. Fix the integration. |
| `permission_denied`, `parameter_invalid` | Never retry |
| Hard declines (`pickup_card`, `fraudulent`) | Never retry the same card |
`wp_token_malformed` gets one retry rather than none for a specific reason: it is normally
a real defect, but it is also what a retired key looks like. One refetch-and-retry covers
the rotation case at negligible cost. A loop on it hammers us with requests that can never
succeed.
Always send an [`Idempotency-Key`](/developers/12-idempotency.md) so a network-level retry cannot
double-charge.
---
### `wp_token_expired`
**Means:** the token was minted more than 10 minutes ago.
**Why it happens:** almost always sealing too early. Tokens queued on your backend before
the charge fires, a token minted at the start of a multi-step flow, or a shopper who left
the tab and came back.
**Do:** seal a new token from the same card data and charge with that. If you no longer
hold the card, re-collect it.
**Prevent:** seal immediately before the charge call, never before an intervening step. See
[the rail reference](/developers/29-sdk-reference.md).
> A clock more than a few minutes off on the *encrypting* host produces this too. `iat`
> comes from your clock, so run NTP.
---
### `wp_token_replayed`
**Means:** this exact token was already used on a previous charge. Every token carries a
one-time id, and we record it for the token's whole lifetime.
**Why it happens:**
- Retrying a **declined** charge with the same token. The first attempt spent it even
though it did not succeed.
- A retry loop, or a network timeout that fired the request twice where the first request
actually landed.
- Reusing one token for two charges: a split payment, a retry at a different amount, an
authorization plus a separate sale.
**Do:** seal a new token and charge again. Do not surface this to the shopper as a decline.
**Prevent:** treat a token as consumed the moment you put it in a request, whatever comes
back. For network-level retry safety, send an [`Idempotency-Key`](/developers/12-idempotency.md): an
idempotent replay returns the original result instead of reaching the token check at all.
> **This is the code you are most likely to hit in production**, because the decline-retry
> path is easy to get wrong. Any "try again" path must re-tokenize.
---
### `wp_token_malformed`
**Means:** the token did not decrypt into a valid envelope. This is deliberately the
*only* answer for every crypto-level failure.
**Why it happens:** it is one of these, and the API will not tell you which.
- The string does not match `^wp:v1:[A-Za-z0-9+/]+={0,2}$`: truncated, whitespace, or
URL-encoded in transit.
- **base64url instead of standard base64.** The body uses the `+/=` alphabet. A `-` or `_`
in your token means you used the wrong encoder.
- The `info` string is not exactly `worthy-card-v1`.
- Wrong cipher suite, or `enc || ciphertext` concatenated in the wrong order.
- The envelope JSON has a missing required field, an extra field, or a malformed one:
`exp` not `MM/YY`, `iat` in milliseconds instead of seconds, `jti` not 32 hex characters.
- The PAN fails Luhn or is not 13 to 19 digits.
- The ciphertext was altered in transit.
- The token was sealed to a key we have since retired. See
[A note on retired keys](#a-note-on-retired-keys).
**Do:** refetch `/wp-config`, seal a new token, and retry **once**. That covers the retired
key. If it recurs, it is a defect in your encryptor: work
[the four steps](/developers/28-card-tokenization.md#building-a-wp-token-yourself) top to bottom. The
base64 alphabet and the `info` string account for most of these.
> **The vagueness is intentional.** Distinguishing "wrong key" from "bad ciphertext" from
> "invalid PAN" would let an attacker use the endpoint as a card-validity oracle. All
> crypto failures return one code, at the same latency.
---
### `wp_token_audience_mismatch`
**Means:** the token was minted for a different merchant account than the one the charge is
billing.
**Why it happens:**
- A **test key on one side and a live key on the other**, the most common cause by far.
- An `aud` copied from another account, or a stale one after an account migration.
- A platform integration that resolves `aud` once and reuses it while charging on behalf
of different sub-accounts.
**Do:** confirm the `aud` in the envelope is the `merchant_account_id` of the account the
charge bills, in the same mode. Keys are prefixed `pk_test_`, `pk_live_`, `sk_test_`,
`sk_live_` precisely so a mode mismatch is visible at a glance.
**Prevent:** derive `aud` from `GET /v1/ping` using the key you are about to charge with,
rather than storing it as configuration. Do not accept it as caller input: a wrong value is
only discovered after the card has already been encrypted.
---
### `wp_token_too_large`
**Means:** the token exceeded 512 characters, so it was rejected without an attempted
decrypt.
**Why it happens:** you added fields to the envelope, or `aud` is far longer than a
merchant account id. A card envelope is 180 to 260 characters, so there is a lot of
headroom and only extra data closes it. Cardholder name, address, metadata, an order id:
none of these belong inside the token.
**Do:** put everything except `pan`, `exp`, `cvv`, `iat`, `jti`, `aud`, `kid` on the charge
body instead, where it is documented and validated. See
[Charges](/developers/03-charges.md).
> Extra fields fail twice over: over 512 characters this code, under it
> `wp_token_malformed`, because unknown keys are rejected. The envelope schema is closed.
---
### `wp_replay_store_unavailable`
**Means:** we could not reach the store that enforces one-charge-per-token.
**HTTP 503**, `type: "api_error"`. Unlike the other six, this one is not your request's
fault, and the shape says so.
**Do:** retry the same request with backoff. This is the only code here where retrying
unchanged can succeed: the token is still valid and still unused.
**Note:** we fail closed. Rather than skip the single-use check we refuse the charge, so a
degraded replay store never becomes a window in which a captured token can be spent twice.
---
### A note on retired keys
There is **no `wp_key_unknown`.** A token sealed to a key we no longer hold comes back as
[`wp_token_malformed`](#wp_token_malformed).
That is deliberate. The `kid` sits *inside* the sealed envelope, so the only way we learn
it names a retired key is by failing to open the token, which is indistinguishable from a
corrupt or tampered one. Reporting those cases differently would tell an attacker which of
them happened, and that difference is enough to turn the endpoint into a card-checking
oracle.
You should not hit this in practice. A rotation window is always longer than the one-hour
config cache, so a client that refetches on schedule never mints under a retired key. If
you see `wp_token_malformed` in a burst that starts right after a rotation, the cause is
almost certainly a **hardcoded** config. See [Key rotation](/developers/31-key-rotation.md).
---
### `raw_pan_rejected`
**Means:** a plain card number reached the API. The request was rejected at the edge,
before it reached anything that processes payments.
**Why it happens:**
- Calling `POST /charges` with `card.number` set to a PAN, without tokenizing.
- Sending the raw card to your own backend and forwarding it.
- A variable wired to the wrong source, so the input value goes out instead of the token.
**Do:** tokenize. Every card must be a `wp:` or an `ev:` token before it is sent.
**There is no setting that allows raw PANs.** Not a permission, not a plan tier, not a
flag. The guard is unconditional, and it exists so that one bug on your side cannot put
your whole environment inside the card data environment. A value that is neither a raw PAN
nor a recognized token is rejected separately as `pan_encryption_required`.
The check runs before the token grammar, so `ev:4242424242424242` is `raw_pan_rejected`
rather than a well-formed token. Wrapping digits in a prefix does not get past it.
---
## Handling them in code
Server-side, around your own sealing function and the charge call:
```js
async function charge(card, amount, idempotencyKey) {
let token = await sealCard(card); // your encryptor
try {
return await postCharge({ token, amount, idempotencyKey });
} catch (err) {
switch (err.code) {
// The token is spent or stale. A fresh one may well succeed.
case 'wp_token_expired':
case 'wp_token_replayed':
token = await sealCard(card);
return await postCharge({ token, amount, idempotencyKey });
// Normally a defect, but also what a retired key looks like.
// One refetch-and-reseal covers the rotation case.
case 'wp_token_malformed':
await refetchWpConfig();
token = await sealCard(card);
return await postCharge({ token, amount, idempotencyKey });
// Integration bugs. Retrying changes nothing. Log loudly.
case 'wp_token_too_large':
case 'wp_token_audience_mismatch':
case 'raw_pan_rejected':
report(err);
throw err;
default:
throw err;
}
}
}
```
Retry **once**, not in a loop. If a fresh token fails the same way, the fault is in the
integration and another attempt will not fix it. Reuse the same `Idempotency-Key` across
the retry so a request that actually landed is not charged twice.
> **Never show a `wp_token_*` code to a shopper.** Every one of them is a fault in the
> request, not a problem with the card. "Your card was declined" sends the shopper to
> another card that will fail in exactly the same way, and you lose the sale twice. Show a
> generic retry prompt and alert your own monitoring.
========================================================================
# Key rotation
The `wp:` tokenization key is rotated periodically. If you fetch `/wp-config` at runtime
and cache it with a real TTL, you will never notice.
> **If you use the Payment Element, you do not need this page.** The frame fetches and
> refreshes the key itself. See [Payment Element](/developers/08b-payment-element.md).
This page is for integrations that hold their own copy of the key: a server that seals
cards before calling `POST /v1/charges`, a native app, or an in-house encryptor. It
explains the mechanism so you can verify you inherit it, and names the one mistake that
opts you out.
---
## **Warning:** DO NOT HARDCODE THE `/wp-config` RESPONSE
**Fetch it at runtime. Refetch every time your cache expires.**
If you paste `public_key` or `kid` into your source, a config file, an environment
variable, or a build artifact, your integration **will break** the moment the key rotates.
It breaks for *every* card at once rather than gradually, and no notification saves you,
because the whole point of the mechanism is that it does not need one.
---
## How it works
Three moving parts:
**1. `/wp-config` carries a key id.**
```json
{
"kid": 1,
"public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
"alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
"info": "worthy-card-v1",
"token_ttl_seconds": 600
}
```
**2. Every token embeds the `kid` it was encrypted with.** You copy it from the config
response into the envelope. When the charge arrives, we know which key opens it without
guessing.
**3. We accept the current key and the previous one simultaneously.** During a rotation
both are live. Tokens minted just before the switch keep working while tokens minted after
it use the new key, so there is no instant at which valid tokens are refused.
Rotation therefore runs: publish the new key, accept both, drop the old key. The overlap
window is comfortably longer than the 1-hour config cache plus the 600-second (10-minute)
token lifetime, so a client that refreshes on schedule never observes the change.
---
## The client's side of the contract
```
Cache-Control: public, max-age=3600
```
Cache `/wp-config` for up to an hour, then refetch. That is the entire obligation.
```python
# Correct: fetched, cached with a TTL, refreshed on expiry
_cache = {"config": None, "expires_at": 0}
def get_wp_config():
now = time.time()
if _cache["config"] and now < _cache["expires_at"]:
return _cache["config"]
resp = requests.get(
"https://api.worthypayments.com/v1/wp-config",
headers={"x-api-key": PUBLISHABLE_KEY},
timeout=5,
)
resp.raise_for_status()
_cache["config"] = resp.json()
_cache["expires_at"] = now + 3600
return _cache["config"]
```
```python
# Wrong: this breaks at the next rotation, for every card at once
WP_PUBLIC_KEY = "BFnYeY4F9PJP1DbNn9eWHc0g…"
WP_KID = 1
```
Take `kid`, `public_key` and `info` from the same response, together. Mixing a cached
`public_key` with a freshly fetched `kid` produces tokens that cannot be opened.
A `503 wp_config_unavailable` must not be cached and must not evict a good cached value.
Retry it; the key is not being served at that moment.
> **Do not cache across a deploy boundary and call it done.** A long-lived process that
> fetches once at boot is a hardcoded key with extra steps. The TTL has to be real.
---
## If a rotation ever does catch you
You get [`wp_token_malformed`](/developers/30-error-codes.md#wp_token_malformed), a `400`.
There is **no dedicated "unknown key" code**, and that is deliberate: `kid` sits inside
the sealed envelope, so a retired key is indistinguishable from a corrupt or tampered
token. Reporting it separately would leak which of those happened and turn the endpoint
into a card-checking oracle. See
[A note on retired keys](/developers/30-error-codes.md#a-note-on-retired-keys).
Recovery is the same either way: refetch `/wp-config`, seal a new token, charge again.
```js
if (err.code === 'wp_token_malformed') {
await refetchWpConfig(); // bypass the cache
const token = await sealCard(card);
result = await postCharge({ token, amount, idempotencyKey });
}
```
**Warning:** Retry **once and only once**. `wp_token_malformed` normally means a genuinely bad
token, so an unconditional loop hammers us with requests that can never succeed. One retry
costs nothing and covers the rotation case; a loop is a bug. If it recurs, your config is
pinned somewhere and no number of retries will help.
Handle it even though it should never fire. It is the difference between a rotation
costing you nothing and a rotation costing you a checkout outage.
---
## Format versions are a different thing
Key rotation changes the key. It does not change the format.
The token prefix `wp:v1:` names the **format**: grammar, envelope schema, cipher suite. A
new prefix (`wp:v2:`) would mean a new cipher suite, and `v1` would keep being accepted
through a long deprecation. That is an announced migration with a timeline, not a silent
background operation.
`alg` in `/wp-config` names the suite explicitly:
```
HPKE-P256-HKDF-SHA256-AES256GCM
```
**Check this string and fail if you do not recognize it.** Treat it as required, not as
"validate if present": a response that omits `alg` would otherwise skip the check
entirely, and the response is the one thing an attacker positioned to answer your fetch
controls. Encrypting with the wrong suite produces a token we cannot open, answered with
the deliberately uninformative `wp_token_malformed`. Failing loudly at config-fetch time is
far easier to debug than failing at charge time.
---
## Checklist
- [ ] `/wp-config` is fetched at runtime, never read from source or env.
- [ ] The response is cached with a real TTL of at most 1 hour.
- [ ] `kid`, `public_key` and `info` all come from the same response.
- [ ] `alg` is checked, and an unrecognized value fails loudly.
- [ ] `wp_token_malformed` triggers one refetch-and-retry. Once, never a loop.
- [ ] No process holds a config in memory for longer than its TTL.
========================================================================
# Migrating from `ev:` tokens
`ev:` and `wp:` are the two rails a server-built charge can use. This page is the
side-by-side for moving a charge you build yourself from one to the other.
> **If you use the Payment Element, this page is not for you.** The element picks the rail
> internally and switches without you doing anything. See
> [Payment Element](/developers/08b-payment-element.md).
> **Both formats work, and `ev:` is not going away.** 3-D Secure requires it. There is no
> deadline here. Move when the rail suits the charge.
---
## Which rail for which charge
| | `ev:` | `wp:` |
|---|---|---|
| Fields | `card.number` and `card.cvv`, separately encrypted | `card.wp_token`, one envelope |
| Encrypt calls per card | Two | One |
| 3-D Secure | **Required.** The challenge provider reads this rail. | Not possible on its own |
| Third-party hop | Evervault | None |
| Expiry | No limit | **600 seconds (10 minutes)** |
| Reuse | Reusable | **Single use** |
| Account binding | Not bound | Bound by the `aud` claim |
Pick `ev:` when the sale may step up to 3-D Secure. Pick `wp:` for a 2D charge where you
want the card sealed directly to us.
---
## Side by side
### The token on the body
```jsonc
// ev: two per-field tokens
{ "card": { "number": "ev:abc123…EncryptedPanToken",
"cvv": "ev:def456…EncryptedCvvToken",
"exp_month": "12", "exp_year": "2030",
"bin": "424242", "last4": "4242" } }
// wp: one envelope, in its own field
{ "card": { "wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…",
"exp_month": "12", "exp_year": "2030",
"bin": "424242", "last4": "4242" } }
```
Note the field, not just the string. The envelope goes in **`card.wp_token`**, and
`card.number` / `card.cvv` are left out. Each field is allowlisted to one grammar, so a
`wp:` value on `card.number` is refused rather than routed. There is no `wp:` CVV: the CVV
is sealed inside the envelope.
`bin` and `last4` are unchanged. Read them from the plain PAN before encryption; they still
travel on the body.
### Validating a token in your own code
```js
// before
if (!token.startsWith('ev:')) throw new Error('not tokenized');
// after: accept both
if (!/^(ev:|wp:v1:)/.test(token)) throw new Error('not tokenized');
```
Widen the guard rather than replacing it, since you will still be minting `ev:` for 3DS.
The server enforces this regardless; the guard is a convenience.
### The encryption itself
```js
// ev: Evervault's SDK, their key, their config endpoint
const ev = await evervault.init(teamId, appId);
const number = await ev.encrypt(pan);
const cvv = await ev.encrypt(cvvValue);
```
```js
// wp: HPKE to our public key, one envelope for the whole card
const config = await fetchWpConfig(); // GET /v1/wp-config, cached 1h
const token = await sealCard({ pan, exp, cvv: cvvValue,
iat, jti, aud, kid: config.kid }, config);
```
Full specification: [Building a `wp:` token yourself](/developers/28-card-tokenization.md#building-a-wp-token-yourself).
Parameters and failure modes: [`wp:` rail reference](/developers/29-sdk-reference.md).
---
## The two limits that break working `ev:` code
`ev:` tolerated patterns `wp:` does not. Neither of these shows up on the happy path, which
is why they reach production.
**Retrying a failed charge with the same token.** Under `ev:` this worked, because the
token was still valid. A `wp:` token is spent by the attempt, whatever the attempt
returned, and the retry is `wp_token_replayed`. Seal a new token before every attempt.
**Sealing early.** Under `ev:` you could tokenize whenever and charge later. A `wp:` token
dies 600 seconds (10 minutes) after `iat`, so anything that sits between sealing and
charging (a queue, an enrichment step, a slow multi-step flow) eventually returns
`wp_token_expired`. Seal immediately before the charge call.
Account binding is the third difference: the envelope carries an `aud` claim naming the
merchant account, and charging a different one is `wp_token_audience_mismatch`. Derive
`aud` from `GET /v1/ping` with the key you are charging with, rather than storing it.
There is no unknown-key error to handle. A token sealed to a retired key comes back as
`wp_token_malformed`, deliberately, so it cannot be told apart from a corrupt token. See
[A note on retired keys](/developers/30-error-codes.md#a-note-on-retired-keys).
All of these are in [Error codes](/developers/30-error-codes.md).
---
## Migration steps
1. **Implement the `wp:` envelope.** Four steps in
[Card tokenization](/developers/28-card-tokenization.md#building-a-wp-token-yourself).
2. **Fetch `/wp-config` at runtime** with a real one-hour TTL. Never pin `kid` or
`public_key`. See [Key rotation](/developers/31-key-rotation.md).
3. **Send the envelope in `card.wp_token`**, and stop sending `card.number` and `card.cvv`
on that charge.
4. **Widen any token-prefix check** in your own code to accept `wp:v1:` as well as `ev:`.
5. **Move sealing to charge time** if it currently runs earlier.
6. **Make retries re-seal.** Audit every path that charges twice from one card: retry
buttons, background retry jobs, split payments.
7. **Keep `ev:` for anything that may step up to 3DS.** The rail is not optional there.
8. **Test the decline path**, not just the happy path. Charge an amount below 1.00 in test
mode (`"amount": 99`; the sandbox has no decline card), run your retry, and confirm you
get a decline rather than `wp_token_replayed`.
Step 8 is the one people skip and the one that catches the real bug.
---
## Running both at once
You do not need a flag day. Each charge names its own rail, so some traffic can be `ev:`
and some `wp:` from the same account in the same hour.
That means you can move one surface at a time: web checkout first, mobile app next,
recurring-billing backend last. Nothing coordinates them.
Saved cards and existing customers are unaffected. A card already stored against a
`payment_method` id is charged the same way regardless of which format tokenized it
originally. The format is a property of the transport, not of the stored card.
---
## Where `ev:` remains
`ev:` is not dead code, and it is not deprecated. It is the rail 3-D Secure runs on, and it
is the fallback path. Keeping your `ev:` handling intact costs nothing and you will still
need it.
If you have infrastructure that only accepts `ev:`-shaped strings (a validator, a stored
column with a length constraint, a log scrubber matching `^ev:`) check it before you send
`wp:`. `wp:v1:` tokens are typically 180 to 260 characters and capped at 512.
> A log scrubber that redacts `ev:` but not `wp:v1:` will start writing card tokens to your
> logs. They are single use and expire in ten minutes, so this is not a PAN disclosure, but
> fix it in the same change.
========================================================================
# Metrics
The same KPI tiles the dashboard shows, over a window you name. Money is integer
minor units (cents). `approval_rate` is a 0..1 fraction; `approval_rate_bps` is
the same figure in basis points (9356 = 93.56%).
```
GET https://api.worthypayments.com/v1/metrics
```
| Scope | Grants |
|-------|--------|
| `metrics:read` | Read the KPI roll-up |
This is a reporting read, not a money-moving call. It does not replace
[charges](/developers/03-charges.md) or [webhooks](/developers/16-webhooks.md) for reconciling a
single payment.
---
## Query
| Parameter | Notes |
|-----------|-------|
| `period` | `today`, `7d`, `30d` (default), `90d`, or `all`. Ignored when both custom dates are set. |
| `start_date` | Custom window start, `YYYY-MM-DD`. Use with `end_date`. |
| `end_date` | Custom window end, `YYYY-MM-DD`. Use with `start_date`. |
```bash
curl "https://api.worthypayments.com/v1/metrics?period=30d" \
-H "x-api-key: sk_test_xxx"
```
```json
{
"object": "metrics",
"gross_volume": 4820000,
"approved_volume": 4510000,
"net_volume": 4390000,
"refunds": 120000,
"approval_rate_bps": 9356,
"approval_rate": 0.9356,
"tx_count": 512,
"approved_count": 479,
"declined_count": 33,
"refund_count": 8,
"dispute_count": 2
}
```
A custom window:
```bash
curl "https://api.worthypayments.com/v1/metrics?start_date=2026-08-01&end_date=2026-08-31" \
-H "x-api-key: sk_test_xxx"
```
Missing `metrics:read` is `403 permission_denied`.
========================================================================
# Changelog
The public API is dated. The current version is **`2026-01-01`**.
There is only one behavioural version today. Sending `API-Version: 2026-01-01`
records intent for the first time a second version ships; it does not select a
different contract yet. See [Versioning](/developers/20-versioning.md). Every change below
therefore applies to every request, whatever `API-Version` it sends.
## What is listed here
Every change that can alter what an existing, correctly-written integration observes is
listed on this page: a response field, HTTP status, error `code` or `type` that changes; a
request that was accepted and is now refused, or the reverse; a webhook payload; a
`worthypayments.js` callback shape or default. Each entry is dated the day it went live in
production (UTC) and tagged:
| Tag | Meaning |
|---|---|
| **Breaking** | Could break a correctly-written integration, as defined in [What counts as a breaking change](/developers/20-versioning.md#what-counts-as-a-breaking-change). |
| **Changed** | Behaves differently, but an integration that follows these guides keeps working. |
| **Added** | A new field, value, event, parameter or callback property. Additive; ignore what you do not use. |
| **Fixed** | The API now does what these guides already said. **Fixed (docs)** means the guide was wrong and now describes what the API does. |
## 2026-10-22
- **Changed.** Absolute links the platform hands out now use `https://app.worthypayments.com`:
the `url` of a checkout link and a payment link, a charge's `receipt_url`, the hosted pay link on
invoices and subscriptions, and the links in the emails we send. Links issued earlier on
`https://app.paygeyt.com` keep working; both hosts serve the same pages. If your code checks the
host of these URLs, or a Content-Security-Policy on your site lists it, allow
`app.worthypayments.com` as well.
- **Changed.** The inbound webhook URLs the dashboard shows you to paste into a provider portal
(PayArc, Kount) now use `https://webhooks.worthypayments.com`. A URL you registered earlier on
`https://webhooks.paygeyt.com` keeps working; both hosts deliver to the same place, so nothing
needs to change in a portal that already has one.
- **Changed.** The Node SDK (`@worthypayments/node`) defaults `baseUrl` to
`https://api.worthypayments.com`. `https://api.paygeyt.com` serves the same API with the same
keys; pass `{ baseUrl: "https://api.paygeyt.com" }` to stay on it.
- **Fixed (docs).** [Charges](/developers/03-charges.md#client-ip-fraud-signal-not-a-body-field),
[Integration hard rules](/developers/27-integration-hard-rules.md#1-client-ip) and three other guides
still warned that a card-testing burst could auto-block your server's IP on a server-side
charge, so renewals needed an allowlist. That stopped being true on 2026-10-19: a charge sent
with a secret key records your server's address apart from the buyer's and never scores or
blocklists it. Nothing to allowlist; send a
[`device_session`](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
to score a server-built charge on the buyer's IP.
## 2026-10-21
- **Added.** The platform answers on worthypayments.com: `https://api.worthypayments.com/v1`,
`https://app.worthypayments.com/sdk/v1/worthypayments.umd.js` and
`https://element.worthypayments.com`, and these guides now show those hosts. The paygeyt.com
hosts are the same API, keys, data and SDK file and keep working, so an existing integration
needs no change. See [Two domains](/developers/README.md#two-domains).
- **Added.** `worthypayments.js` loaded from `app.worthypayments.com/sdk/v1/` defaults to
`https://api.worthypayments.com/v1` and `https://element.worthypayments.com`, so a page that
loads the SDK from worthypayments.com stays on that domain. Loaded from
`app.paygeyt.com/sdk/v1/`, bundled from npm, or hosted on your own site, it keeps the
paygeyt.com hosts it always used. `apiBase` and `elementOrigin` still override both. See
[Embedded SDK](/developers/08-embedded-sdk.md).
- **Added.** WooCommerce plugin 2.2.0: an order whose buyer never came back after paying (the
page closed after 3-D Secure, a lost redirect) is settled even without a webhook. The plugin
reads the order's intent itself 10 minutes after the checkout handed it out, then after 30
minutes, 2 hours and 12 hours. Before WooCommerce's **Hold stock** cleanup cancels an unpaid
order, the plugin reads its intent once more, and an order whose payment succeeded or is
still settling is never cancelled. See [How a payment works](/developers/09-woocommerce.md#how-a-payment-works).
- **Changed.** WooCommerce plugin 2.2.0: `dispute.created` for a chargeback puts a paid order
**On hold**, `dispute.won` restores the status it had, and `dispute.lost` marks it
**Failed**, as Stripe's WooCommerce plugin does. A pre-dispute alert and the other dispute
events still only add an order note, and `dispute.closed` no longer adds one (it repeats the
`dispute.won` / `dispute.lost` it comes with). These status changes send the customer no
WooCommerce order e-mail. See [Disputes](/developers/09-woocommerce.md#disputes).
- **Added.** WooCommerce plugin 2.2.0: the order screen shows the card the order was paid with
(brand and last four), its mode, and a link to the transaction in your dashboard; the
transaction id next to **Payment via** links there too. See
[The order screen](/developers/09-woocommerce.md#the-order-screen).
- **Added.** WooCommerce plugin 2.3.0: saving the settings checks every key with
`GET /v1/ping`. It reports a refused key, a key of the other mode, each missing secret-key
scope, and a publishable key whose allowed domains do not list the site. While the method is
on in test mode, every admin screen shows a test-mode notice. See
[Configure](/developers/09-woocommerce.md#configure).
- **Added.** WooCommerce plugin 2.3.0: **Set up the webhook for me** (on by default) registers
the store's endpoint with `POST /v1/webhook-endpoints` when a mode's signing secret is empty,
with the `charge.*`, `dispute.*`, `pre_dispute_alert.*` and `setup_intent.*` events the
plugin uses, and saves its signing secret; an existing endpoint for the same URL gets its
secret rolled instead. Needs `webhooks:read` and `webhooks:write` on the secret key. See
[Webhooks](/developers/09-woocommerce.md#webhooks).
- **Added.** WooCommerce plugin 2.3.0: card-testing protection. A buyer, counted by IP
address, WooCommerce session and billing e-mail, may make 10 payment attempts an hour (the
**Payment attempts per hour** setting; `0` turns it off); past that, checkout refuses the
attempt before it reaches our API. See [Card testing](/developers/09-woocommerce.md#card-testing).
- **Added.** WooCommerce plugin 2.4.0: a renewal declined `soft_decline_3ds` (the bank wants the
customer to confirm the payment, and a renewal never runs 3-D Secure) e-mails the customer
**Renewal payment needs confirmation**, with a link to the renewal's payment page, as Stripe's
plugin does; WooCommerce Subscriptions' failed-renewal and retry e-mails and WooCommerce's
failed-order e-mail are not sent for it. See
[WooCommerce Subscriptions](/developers/09-woocommerce.md#woocommerce-subscriptions).
- **Added.** WooCommerce plugin 2.4.0: My account → Subscriptions and the admin's subscription
screens name the card renewals charge (*Via Visa ending in 4242*).
- **Breaking.** WooCommerce plugin 2.4.0: saved-card payments and renewals carry the same
`metadata` keys as payments made in the Element: `order_id` and `order_number`, plus
`subscription_id` on a renewal. They carried `wc_order_id`, `wc_subscription_id` and
`wc_site`; read the new keys in `charge.*` webhooks and `GET /v1/charges`. Charges made
before the update keep their old keys, so code that reads an order id from a charge should
read `order_id` and fall back to `wc_order_id`.
- **Changed.** WooCommerce plugin 2.4.0, classic checkout and the pay-for-order page: placing
an order with a saved card waits up to 2 seconds for the buyer's device signals, as the
Checkout block already did, so the charge carries a current device session.
- **Added.** Payment Element: while 3-D Secure runs for a confirm from the card form, the
element covers the page with a dimmed layer, a spinner and "Verifying your card…" in the
element's `locale`. The bank's challenge sits above it; clicks on the page are blocked and
focus is held, so the page's own submit button cannot be pressed twice. It comes down
before `onSuccess` or `onError` runs. On by default, with no option, as Stripe draws its own
3-D Secure UI. See [The 3-D Secure overlay](/developers/08b-payment-element.md#the-3-d-secure-overlay).
- **Changed.** Payment Element: `onEscape` is not called while 3-D Secure runs, and Escape
pressed on the page then does not reach the page's own key handlers. A dialog that closed on
Escape tore the element down in the middle of the authentication.
- **Changed.** Payment Element: a confirm waits at most 2.5 s for the device fingerprint and
the Kount session, and the element computes the fingerprint while the buyer types. A
buyer's first Pay could wait up to 10 s for the fingerprint library and then for the
fingerprint itself with no limit. A confirm made before the element's fingerprint library had
loaded also no longer goes out without a fingerprint.
- **Fixed.** Checkout links (hosted checkout): the Apple Pay sheet asks the buyer for their
name, email and billing address. It asked for none of them, so when the checkout had no
customer email yet (none typed on the page), the buyer confirmed in the sheet and the
payment was then refused with nothing charged, and Apple Pay payments reached you with no
customer name and no billing address for AVS. Google Pay is unchanged: it already asked for
the email and the full billing address.
- **Added.** `worthypayments.js`: `mountExpressCheckout()`, Apple Pay and Google Pay buttons
with no card form, for a product page, a cart or above your checkout (Stripe's Express
Checkout Element). The buyer picks the shipping address and method inside the sheet; your
`onAddressChange` / `onMethodChange` answer the new total. Nothing is created until the
buyer authorizes: `createIntent` then receives the buyer's email, name, phone, billing and
shipping address and the chosen method, so a guest can pay. A total that differs from the
one the sheet showed last charges nothing (`amount_changed`), and an address you cannot ship
to is refused before `createIntent` runs (`shipping_unavailable`). See
[mountExpressCheckout](/developers/08-embedded-sdk.md#mountexpresscheckouttarget-opts).
- **Fixed.** `worthypayments.js` Apple Pay buttons (Payment Element): tapping the button
opens the Apple Pay sheet. The SDK asked for the buyer's details in a shape Evervault's
Apple Pay no longer accepts, and the sheet failed before it opened.
- **Fixed.** `worthypayments.js` Google Pay buttons (Payment Element): the sheet asks for the
full billing address and phone, and the payment carries the buyer's name and address for
AVS, as the guides said. Only the email came back.
- **Changed.** `worthypayments.js`: with `recurring` set, Google Pay offers only the cards it
hands back as the card itself (`PAN_ONLY`), which are saved and can renew. A card on the
buyer's Android device alone (a device token, `card_not_saveable`) is no longer offered on a
subscription. See
[Subscriptions paid by wallet](/developers/08b-payment-element.md#subscriptions-paid-by-wallet).
- **Changed.** WooCommerce plugin 2.5.0: **Express checkout** shows the Apple Pay and Google
Pay buttons themselves on product, cart and checkout pages and in the Cart and Checkout
blocks, instead of a button that opened a payment sheet. Guests can pay, and the buyer picks
the shipping address and method in the wallet sheet, each priced by WooCommerce; the order is
placed with the sheet's e-mail, name, phone, billing and shipping address only when the buyer
authorizes. With it on, the checkout's own payment form shows no second set of wallet
buttons. See [Express checkout](/developers/09-woocommerce.md#express-checkout).
- **Added.** WooCommerce plugin 2.5.0: a subscription can be paid with Google Pay through
Express checkout; its card is saved and renewals charge it. Apple Pay is not offered for a
subscription (its token cannot be saved yet). See
[Express checkout](/developers/09-woocommerce.md#express-checkout).
- **Added.** WooCommerce plugin 2.5.0: the plugin serves Apple's domain verification file at
`/.well-known/apple-developer-merchantid-domain-association`, and saving the settings while
Apple Pay is on checks that the site serves it. See
[Apple Pay on your domain](/developers/09-woocommerce.md#apple-pay-on-your-domain).
- **Added.** WooCommerce plugin 2.5.0: deleting the plugin removes its settings, caches and
scheduled jobs, never an order, payment record or saved card; a translation template ships
in `languages/`. The plugin now requires WordPress 6.2 (WooCommerce 8.0's own minimum). See
[Install](/developers/09-woocommerce.md#install).
## 2026-10-20
- **Added.** `GET` and `POST /v1/fraud/three_ds_policy`: read, turn off, or move the
risk-score 3DS step-up (by default a charge scoring 70–95 is stepped up). If you decide
3DS yourself with `three_ds.request`, send `{ "risk_step_up": { "enabled": false } }` so
your rules are the only trigger. Needs `fraud:read` / `fraud:write`. Nothing changes until
you call it. See [Fraud checks](/developers/23-fraud-checks.md#the-risk-score-3ds-step-up).
- **Fixed.** Payment Element: an `element.update()` called before the element has finished
loading is no longer lost. An early `update({ clientSecret })` left the element confirming
the previous intent, and an early `locale`, `appearance` or `defaultValues` did not show.
See [Payment Element](/developers/08b-payment-element.md#2-mount-the-element-browser).
- **Fixed.** Payment Element: `element.update({ defaultValues })` followed by
`element.submit()` in the same tick now confirms the billing details the update carried.
The submit used the values from before the update, so a page that hands over its own
billing form at submit confirmed with no billing address, and the issuer had nothing to
check for AVS. In the same way `update({ clientSecret })` followed by `submit()` confirmed
the previous intent, and a `submit()` sent as the element finished loading was dropped.
See [Payment Element](/developers/08b-payment-element.md#three-ready-made-forms).
- **Fixed.** Payment Element: `defaultValues.billingDetails.address.line2` reaches the
payment, from the card form and as the Apple Pay / Google Pay default. It was dropped, and
the `BillingDefaultValues` type did not accept it. It rides with `line1`, and is not sent
when the buyer types a different street.
- **Changed.** Payment Element: when 3-D Secure does not authenticate the card
(`three_ds_failed`) or the buyer closes the challenge (`three_ds_cancelled`), the form and
`onError`'s `message` give the buyer's sentence in the element's `locale`, as for a
decline. They gave an English diagnostic ("3DS challenge cancelled.", "3DS authentication
failed (…)."); `code` and `type` are unchanged. A decline with `decline_code:
three_ds_failed` now reads the same sentence instead of "Your card was declined. Try another
card." The form's "Please complete the payment details." is in Spanish on a Spanish form.
See [Payment Element](/developers/08b-payment-element.md#errors-worth-recognising).
- **Fixed.** Payment Element: a 3-D Secure authentication that completes without a challenge
no longer grows the element to 620 px for a moment. The page below it jumped by about
400 px and back.
- **Fixed.** Payment Element: the iframe's height is set with `!important`, so a page
stylesheet such as `iframe { height: auto !important; }` no longer draws the form at the
browser's default 150 px, clipped.
- **Fixed.** A charge with an attempted 3-D Secure authentication on Mastercard (ECI `01`) is
now recorded as shifting liability, as an attempt on Visa, Amex and Discover (ECI `06`)
already was: `three_ds.liability_shift` is `true` on `GET /v1/charges/{id}`, the `charge.*`
webhooks' `detail.threeDsLiabilityShift` is `true`, and the dashboard counts the charge as
liability-shifted. It was recorded as not shifted. Charges recorded before this change are
corrected; no webhook is sent for them. See [3-D Secure](/developers/07-3ds.md#liability-shift).
- **Changed.** While your account is not yet live, the test-mode transactions we sync back from
NMI come from your own NMI sandbox account, the one whose key you added under Settings >
Integrations. They used to come from the sandbox account the platform shared between
accounts. Without your own sandbox key nothing is synced back in test mode. Test transactions
synced earlier from the shared account are not changed or removed. Live mode is unchanged.
See [Testing](/developers/15-testing.md#your-own-nmi-sandbox-key).
- **Changed.** WooCommerce plugin 2.1.0: an order that starts or renews a subscription always
saves its card. Its payment intent is created with `setup_future_usage: "off_session"` and the
order's customer (so a buyer who creates their account at checkout is covered), the Element
draws no **Save this card** box and no Apple Pay / Google Pay buttons for such a cart, and the
checkout tells the buyer the card is kept for the renewals. For such an order the Store API's
`worthypayments_save_card` is now `never`, and the new `worthypayments_setup_future_usage` is
`off_session`. See [WooCommerce Subscriptions](/developers/09-woocommerce.md#woocommerce-subscriptions).
- **Added.** WooCommerce plugin 2.1.0: a $0 subscription sign-up (a free trial) saves its card
with no payment, through a [setup intent](/developers/08b-payment-element.md#saving-a-card-without-a-payment-setup-intents),
on the classic checkout, the Checkout block and the Store API. The Store API answers
`worthypayments_setup_intent` (`seti_…`) and its client secret instead of a payment intent;
the second call sends `payment_data` `worthypayments_setup_intent`. The plugin completes such
an order on `setup_intent.succeeded` too: add the `setup_intent.*` events to your store's
webhook endpoint. See [Headless checkout](/developers/09-woocommerce.md#headless-checkout-store-api).
- **Added.** WooCommerce plugin 2.1.0: **My account → Payment methods → Add payment method**
saves a new card with no payment, and a subscription's **Change payment** page takes a new
card as well as a saved one. See [Saved cards](/developers/09-woocommerce.md#saved-cards).
- **Added.** WooCommerce plugin 2.1.0: every pending Store API answer carries
`worthypayments_intent_type` (`payment_intent` or `setup_intent`), and
`GET /wc/store/v1/cart` carries `extensions.worthypayments.subscription` (the cart starts a
subscription). See [Headless checkout](/developers/09-woocommerce.md#headless-checkout-store-api).
- **Changed.** WooCommerce plugin 2.1.0, classic checkout and Checkout block: Place order is
answered *The payment form is still loading* (and nothing is placed) until the card form has
loaded; while a payment confirms, the rest of the form is frozen and a busy line shows; a card
form that loads late clears its "could not load" notice; and a declined `soft_decline_3ds`
saved card switches the Checkout block to the new-card form.
- **Fixed (docs).** WooCommerce's Store API second call must repeat the whole first body
(`billing_address` and `payment_method` are required on every call) with an unchanged cart,
or `POST /wc/store/v1/checkout/{order_id}` finishes one particular order. The guide said to
post the payment data alone. See [Headless checkout](/developers/09-woocommerce.md#headless-checkout-store-api).
## 2026-10-19
- **Fixed (docs).** An attempted 3-D Secure authentication on Mastercard (ECI `01`) normally
shifts liability, as an attempt does on Visa, Amex and Discover (ECI `06`). The guide said
Mastercard's did not. `liability_shift` on the 3DS session, which is the network's own
answer, is unchanged. See [3-D Secure](/developers/07-3ds.md#what-the-eci-says).
- **Fixed (docs).** Wallets: a Google Pay `PAN_ONLY` card carries no cryptogram, does not
satisfy SCA and can be stepped up like a card, and a Google Pay payment shifts liability
only on ECI `05` (Visa) or `02` (Mastercard). The guides said every wallet satisfied SCA and
carried its own shift. See [Charges](/developers/03-charges.md) and [3-D Secure](/developers/07-3ds.md).
- **Fixed (docs).** The capture windows (7 days; 4 days 18 hours for a Visa merchant-initiated
charge) are ours and fit inside the card networks' own limits; the guides called them the
networks' windows. An authorization canceled at the end of its window is not voided at the
gateway, so the buyer's hold lasts until their bank drops it; the guide said the bank had
already released it. See [Authorize and capture](/developers/17-auth-capture.md).
- **Fixed (docs).** `description` does not reach the buyer's card statement, and a charge's
`statement_descriptor` is the descriptor we stored, not necessarily the text the bank
prints. The guide said both did. See [Charges](/developers/03-charges.md).
- **Fixed (docs).** Subscription renewals are merchant-initiated and charged without 3-D
Secure; the guide said every renewal ran it. See [Subscriptions](/developers/18b-subscriptions.md).
In [WooCommerce](/developers/09-woocommerce.md), Subscriptions retries a declined renewal only when
its **Retry Failed Payments** setting is on, which is off by default.
- **Added.** `metadata` on payment intents: your own key/value pairs on
`POST /v1/payment_intents`, with the `POST /v1/charges` rules and error messages. It is
answered with `reference` (on create, on update, and on retrieve to secret and restricted
keys; never to a publishable key), copied onto the charge the intent makes when the buyer
pays (so `GET /v1/charges/{id}` and the `charge.*` webhooks' `detail.metadata` carry it),
and carried by `checkout.session.completed`. A GDPR customer deletion clears it.
- **Added.** `PATCH /v1/payment_intents/{id}` takes `reference`, `customer`, `receipt_email`
and `metadata`, as well as the amount, while the intent is still open (the same states as
a re-price; a paid, in-flight or expired intent is still a `400 failed_precondition`).
`metadata` is merged as on Stripe: a key sent as `""` is removed, keys not sent are kept,
at most 50 keys after the merge. `reference` and `receipt_email` are cleared with `""` or
`null`. `customer` is checked like on create, cannot be removed, and cannot change once a
payment attempt on the intent started (`400 failed_precondition`, `param: customer`).
`amount` is no longer required when the request changes a detail; a request with neither
is refused on `amount` as before. The WooCommerce plugin now sends the order's `order_id`
and `order_number` as the intent's metadata. See
[Payment Element](/developers/08b-payment-element.md#metadata-and-the-other-details).
- **Fixed.** `PATCH /v1/payment_intents/{id}` changes payment intents only, as documented.
A `pi_…` id built from another checkout session (a payment link's, an invoice's, a
subscription's first payment, a store cart) is now a `404` `resource_missing` and nothing
changes. It used to re-price that open session, so its buyer could be charged an amount
other than the link or invoice they were sent.
- **Changed.** Apple Pay and Google Pay run on the `EVERVAULT` rail only.
`GET /v1/wallet-config` no longer has `collectJsTokenizationKey`, and its `provider` is
`EVERVAULT` or `NONE`. In `worthypayments.js`, `WalletProvider` is `"EVERVAULT" | "NONE"`,
and `mountWalletButton()` no longer loads the gateway's Collect.js script. A wallet that
works today keeps working. See [Payment Element](/developers/08b-payment-element.md).
- **Added.** Device sessions give a charge your server sends the buyer's own IP address and
browser. `wp.getDeviceSession()` in worthypayments.js calls the new
`POST /v1/device_sessions` from the buyer's browser with your publishable key and resolves a
`ds_…` id; send it as the new optional `device_session` on `POST /v1/charges`. A session
lasts 30 minutes, belongs to the account and mode that created it, and may go on more than
one charge in that time. An unknown or expired one is a `400` with
`param: "device_session"` (`resource_missing` or `device_session_expired`) that does not
consume the `Idempotency-Key`. A charge without `device_session` behaves as before, except
for what it is scored on: with a secret or restricted key, the IP we see is your server's,
and it is now recorded as your server's address and no longer counted as a buyer IP by
per-IP velocity, the IP blocklist or the cascade, so one server address can no longer get
every buyer of your store blocked. Responses do not change. The WooCommerce plugin sends the
session with a saved-card payment. See
[Device data collection](/developers/24-device-data-collection.md#device-session-the-buyers-ip-and-browser).
- **Added.** `device_session` on `POST /v1/payment_intents/{client_secret}/confirm` and
`POST /v1/setup_intents/{client_secret}/confirm`, for a confirm your server sends. The same
rule as on charges now applies to both: confirmed with a secret or restricted key, your
server's IP is no longer scored as the buyer's. A confirm from the buyer's browser, the
Payment Element's included, is unchanged. Responses do not change; an unknown or expired
session is a `400` with `param: "device_session"` and nothing is charged or verified.
- **Added.** `GET /v1/charges/{id}?expand[]=attempts` lists every processor the charge was
tried on, in order: the processor, how the attempt ended (`approved`, `declined`, `error`,
`timed_out` or `pending`, with `reconciled: true` when a lost answer was confirmed
approved at the gateway), its `decline_code` and `response_code`, whether it was a cascade
step, when it was sent and how long it took. `final` marks the attempt that produced the
result. A fraud-blocked charge answers `[]`. Without `expand[]` the charge is unchanged,
and an `expand[]` value we do not have is ignored. Not on `GET /v1/charges`, and never
with a publishable key. See
[Charges](/developers/03-charges.md#which-processors-it-was-tried-on-expandattempts).
- **Fixed.** `created[gte]`, `created[gt]`, `created[lte]` and `created[lt]` on
`GET /v1/charges` and `GET /v1/customers` now filter the list, as
[Pagination](/developers/13-pagination.md#filtering-by-creation-time) describes. Until now these bounds
were ignored: the list came back unfiltered, newest first, as if no `created` had been
sent. Only a bare `created=` worked. If your integration sends these bounds and
relied on getting every row back, it now gets only the rows created in that window. The
bounds are now also checked: one in milliseconds, or not a whole number of seconds, is a
`400` `parameter_invalid_integer` naming it, and a bound sent twice, or `created=` sent
together with `created[...]`, is a `400` `parameter_invalid`. A list call without
`created[...]` behaves as before.
## 2026-10-18
- **Breaking.** Test mode needs your own NMI sandbox key. A test-mode call that reaches the
gateway (a charge, a payment intent or setup intent confirm, saving a card, a capture, void
or refund) on an account without one is refused with `403` `sandbox_key_required`. It used
to run on a sandbox account the platform shared between every account, whose saved-card
storage had filled up, so test card saves were failing for everyone. Add your key in the
dashboard under Settings > Integrations (NMI card, test mode); no NMI sandbox account yet:
create one at [guide.nmi.com](https://guide.nmi.com/). Test cards saved before this change
sit in the shared account and can no longer be charged: remove them and save them again.
Live mode is unchanged. See [Testing](/developers/15-testing.md#your-own-nmi-sandbox-key).
- **Fixed.** `wp.getFingerprint()` now returns the browser fingerprint on your own pages, as
[Device data collection](/developers/24-device-data-collection.md#browser-fingerprint) describes. On
any page outside our domain it resolved `null`, because the browser refused the fingerprint
library the SDK loads, so a charge you build server-side carried no `device.fingerprint`.
Your code does not change. The Payment Element was not affected.
- **Fixed.** A refusal by the card gateway that is not about the billing address (a duplicate,
an invalid expiry, a vault the gateway would not write) no longer reads as an address
failure: its `decline_code` is `processing_error` with "The transaction could not be
processed.", where it was `avs_rejected` with "The billing address could not be verified."
A refusal by your gateway's address rule is still `avs_rejected`. This shows on a setup
intent's `last_setup_error`.
- **Changed.** `POST /v1/charges` on a saved card with `"initiated_by": "customer"` (the buyer
paying now on your checkout) now applies your 3-D Secure rules, as the Payment Element does:
the route's **Attempt 3DS**, the 3DS risk band, a customer marked **Require 3DS** and a fraud
rule asking for 3DS. The card is authenticated on the encrypted copy kept when it was saved:
the charge answers `requires_action` with `next_action.three_ds.session_id`, and you resubmit
it with `three_ds.session_id` under the same `Idempotency-Key`. A card with no such copy
(saved before 2026-10-18, or imported from your gateway vault) is declined `402
card_declined`, `decline_code: soft_decline_3ds`, with nothing charged: ask the buyer to enter
the card again. These charges used to go through without 3DS. `three_ds.request` is now
accepted on such a charge; it is still a `400` on a merchant-initiated saved-card charge,
which stays without 3DS. See [3DS on a saved card](/developers/07-3ds.md#3ds-on-a-saved-card).
- **Fixed.** A hosted-checkout or Payment Element payment left on "processing" by an attempt
that died before reaching the gateway now resolves as not charged after 15 minutes, so the
buyer can pay again; it used to stay processing for up to 24 hours. An attempt that was paid
meanwhile resolves as paid.
- **Fixed.** A decline-recovery retry that stalled without taking any money no longer blocks its
recovery: it is retried at the next scheduled time, or the recovery ends at its last attempt
(`decline_recovery.failed`). A recovery whose original payment has since been paid stops
without charging again.
- **Fixed.** A capture or void that stalled and finds the payment already moved on (a later
void, an expiry, a void that completed meanwhile) books nothing and answers the same
`failed_precondition` refusal as any charge that cannot be captured or voided, instead of
overwriting the newer state.
- **Fixed.** A charge whose gateway answer arrived after our background reconciliation had
already settled it is no longer booked a second time: the charge answers with the settled
result, and an approval the reconciliation had closed as not found takes the payment back.
- **Fixed.** A charge on a saved card is sent with the gateway account that holds the card. A
saved card exists in one processor account only, and a charge routed to another account used
to fail; now the charge moves to the card's own account, and a fallback route is tried only
on that same account. Responses are unchanged.
- **Changed.** Cards imported from your existing NMI customer vault keep the stored-credential
series your own NMI integration began, so their renewals reference the original transaction.
Removing an imported card here never deletes it at NMI; your own integration may still use it.
- **Added.** `worthypayments.js`: a `recurring` option on `mountPaymentElement` for a payment
that starts a subscription. Apple Pay opens as a recurring payment request (your billing
agreement, cadence, optional trial and a management link), and a wallet payment asks to save
its payment method, as a ticked "save my card" does. A Google Pay card returned as the plain
card is saved and can renew; Apple Pay and tokenised Google Pay still answer
`card_save: skipped` / `card_not_saveable` for now. Amounts are in minor units. See
[Payment Element](/developers/08b-payment-element.md).
- **Added.** Tax rates for your own store (secret or restricted keys): `GET`/`POST
/v1/tax_rates`, `GET`/`POST`/`DELETE /v1/tax_rates/{id}` (delete is a soft delete), attach
and detach a rate on a product with `POST /v1/products/{id}/tax_rates` and
`DELETE /v1/products/{id}/tax_rates/{tax_rate}`, and `POST /v1/tax/calculate` for up to 100
lines. Rates are percent strings with at most two decimals (`"7.25"`). New scopes `tax:read`
and `tax:write` (server-only), not on new keys by default.
- **Added.** Fraud controls for your own tools (secret or restricted keys): `GET`, `POST` and
`DELETE /v1/customers/{id}/three_ds` read, set and clear a customer's Require 3DS flag;
`GET /v1/fraud/rules` lists your fraud rules and `GET /v1/payment_workflow/routes` your payment
routes, read-only. New scopes `fraud:read` and `fraud:write` (server-only). A platform rule
still outranks a flag or a request. See [Fraud checks](/developers/23-fraud-checks.md).
- **Changed.** A 3-D Secure session left pending for more than 24 hours can no longer be
completed; start a new one. Deleting a customer now also clears the buyer details kept on
their pending 3DS sessions. See [3-D Secure](/developers/07-3ds.md).
- **Changed.** 3-D Secure now applies to a saved card the buyer pays with in the Payment
Element or on the hosted checkout page. Your own rules apply to it (a route with Require
3DS on, your 3DS policy, a fraud rule asking for a challenge), and so does
`three_ds.request` on a payment intent; such a card used to be charged without 3-D Secure.
The card is authenticated where it is: the answer is `requires_action`, and the buyer
completes the bank's check without re-entering the card. A card saved before this date, or
imported, has no copy to authenticate: that attempt is declined `soft_decline_3ds` with
nothing charged, the buyer enters the card once more, and it is kept for the next time.
On `POST /v1/charges`, a buyer-present saved-card charge follows the same rules (see the
`initiated_by` entry above); a merchant-initiated one still refuses `three_ds.request`
(`400`), and the error now says to collect the card again. See
[3DS on a saved card](/developers/07-3ds.md#3ds-on-a-saved-card).
- **Added.** `setup_future_usage` (`off_session` | `on_session`) on
`POST /v1/payment_intents`: the card the intent is paid with is saved to the intent's
`customer` whatever the buyer chooses, as the card's initial stored-credential payment. The
Payment Element draws no "save this card" box for such an intent; a `mode: 'payment'` mount
takes `setupFutureUsage` for the same. `customer` is required with it (400
`parameter_missing`, `param: customer`). Every read of the intent answers
`setup_future_usage` (`null` when unset). See
[Always saving the card](/developers/08b-payment-element.md#always-saving-the-card-subscriptions).
- **Added.** Setup intents save and verify a card with no payment: `POST /v1/setup_intents`
(secret key, `customers:write`), `GET /v1/setup_intents/{client_secret}` and
`POST /v1/setup_intents/{client_secret}/confirm`. The confirm runs a $0 verification that
saves the card to the customer as its initial stored-credential transaction, with 3-D Secure
when `three_ds.request` asks for it. The Payment Element mounted with a setup intent's
`client_secret` (`seti_…`) is a card form with a "Save card" button, and `onSuccess`
receives `setupIntentId` and `payment_method`. New webhook events `setup_intent.succeeded`
and `setup_intent.setup_failed`. See
[Saving a card without a payment](/developers/08b-payment-element.md#saving-a-card-without-a-payment-setup-intents).
- **Added.** `POST /v1/setup_intents/{id}/cancel` (secret key, `customers:write`) cancels an
open setup intent, with an optional `cancellation_reason` (`abandoned` | `duplicate` |
`requested_by_customer`); a canceled intent takes no card (400
`setup_intent_unexpected_state`). `GET /v1/setup_intents` (secret key, `customers:read`)
lists them newest first, cursor-paged, optionally for one `customer`. Every setup intent
now answers `cancellation_reason` (`null` unless canceled) and may read `status: canceled`.
- **Added.** A setup intent's confirm meets the same fraud screening as a payment (your
blocked customers and cards, card-testing detection, your velocity rules) before the card
is verified. A refusal is a declined attempt: 200 `requires_payment_method` with
`last_setup_error` `card_declined` / `decline_code` `fraudulent`, counted toward the
intent's five attempts.
- **Added.** `PATCH /v1/subscriptions/{id}` takes `default_payment_method` (`pm_…`): the card
later renewals charge, switched with no charge now, typically the one a setup intent just
saved. It must be an active saved card of the subscription's customer (else 404). See
[Changing a subscription's card](/developers/08b-payment-element.md#changing-a-subscriptions-card).
- **Added.** `worthypayments.js`: `mountPaymentElement({ mode: 'setup', createIntent })`
mounts the element before its setup intent exists; `createIntent` resolves the `seti_…`
secret your server created. A deferred mount now refuses a secret of the other kind
(`invalid_client_secret`): a setup intent's on `mode: 'payment'`, or a payment intent's on
`mode: 'setup'`.
- **Changed.** A merchant-initiated charge on a saved card (a subscription renewal, an invoice
charge, or `POST /v1/charges` on a saved card) is never sent to 3-D Secure: there is no buyer
to complete a challenge, so it is charged without one. A customer flagged Require 3-D Secure
or a fraud rule asking for a challenge used to decline it `soft_decline_3ds`. A fraud rule
that blocks still blocks it. See [3-D Secure](/developers/07-3ds.md).
- **Changed.** Automatic decline recovery now retries only charges we schedule ourselves with
no buyer present: renewals of our subscriptions and invoice charges. A declined
`POST /v1/charges` is no longer retried by us, so your own billing system's retry rules are
the only retry and a renewal cannot be charged twice. A saved card the buyer chooses at the
hosted checkout or in the Payment Element is not retried later either: the buyer sees the
decline and can pay another way. Responses are unchanged. See [Charges](/developers/03-charges.md).
- **Added.** `worthypayments.js` mounts the Payment Element before a payment intent exists:
`mountPaymentElement(target, { mode: 'payment', amount, currency, createIntent })`, Stripe's
`elements({ mode: 'payment', amount, currency })`. `createIntent` creates the intent on your
server when the buyer pays (Pay, `element.submit()` or an approved wallet sheet) and resolves
its `client_secret`; the element then confirms it as before. `amount` is the wallet sheet
total; the intent's amount is the one charged. `element.update({ amount, currency })` redraws
the wallet buttons on such a mount. New error codes: `intent_creation_failed` on `onError`;
`client_secret_with_mode`, `invalid_mode`, `invalid_amount`, `invalid_currency`,
`missing_create_intent` at mount, and `update_not_supported` from `update()`. A
`clientSecret` mount is unchanged. See
[Mount before the order exists](/developers/08b-payment-element.md#mount-before-the-order-exists).
- **Added.** `three_ds.request` on `POST /v1/charges`, `POST /v1/payment_intents` and
`POST /v1/3ds/sessions`: `any`, `challenge` or `challenge_mandated` asks for 3-D Secure on
that payment even when no rule does (`automatic`, the default, leaves it to your rules). A
rule always outranks a request. Only on a card entered for this payment. `worthypayments.js`
types it on `CreateChargeParams`. See [3-D Secure](/developers/07-3ds.md).
- **Breaking.** A charge resubmitted with a `three_ds.session_id` whose session did not
authenticate is refused instead of being charged without authentication: `402`
`three_ds_failed` or `three_ds_card_not_enrolled` (stored under the `Idempotency-Key`), `409`
`three_ds_session_incomplete` while it is still pending, and `503`
`three_ds_result_unavailable` when the result cannot be read (both released for a retry).
An unknown key inside `three_ds` (or `session_id: null`) is a `400`.
- **Added.** `failure_reason` on `GET /v1/3ds/sessions/{id}`: a normalised reason such as
`card_not_enrolled` or `authentication_failed`.
- **Added.** A charge's `metadata` is stored: `GET /v1/charges/{id}`, the charge list and the
charge webhooks (`detail.metadata`, including `charge.canceled` and `charge.expired`) return
what you sent on `POST /v1/charges`; it used to come back only on the create response. It is
cleared if the customer is deleted (GDPR), everywhere it is copied.
- **Breaking.** `metadata` on `POST /v1/charges` is validated: up to 50 keys, each 1-40
characters without square brackets, each value a string of up to 500 characters. Anything
else is a `400` `parameter_invalid` with `param: metadata`; such a body was accepted and
ignored before.
- **Changed.** A charge on a saved card (API, Payment Element, subscription renewal, invoice)
now sends the card network the transaction id of the payment the card was saved from,
when one was recorded, and a decline-recovery retry goes out as a merchant-initiated
charge on the stored card. Responses are unchanged.
- **Added.** `initiated_by` on `POST /v1/charges` with a saved payment method: `customer`
when the cardholder is paying now with a card they saved, `merchant` (the default) for a
merchant-initiated charge. Sending it with a fresh card, a wallet, `wp_token` or ACH, or
`customer` with `recurring: true`, is a `400` `parameter_invalid`.
- **Changed.** Card networks now receive the credential-on-file indicators they expect: the
charge that saves a card is flagged as the stored credential's first use, a saved card used
by the buyer at checkout is customer-initiated, and the first charge of a subscription
(hosted checkout, or a store or checkout-link sale of a subscription product) opens the
recurring series. Responses are unchanged.
- **Added.** `worthypayments.js`: appearance selectors for placeholders
(`.Input::placeholder`), the save-this-card checkbox (`.Checkbox`, `.CheckboxInput`,
`.CheckboxLabel`), the card brand mark (`.CardBrand`), a saved card under the pointer
(`.SavedItem:hover`) and the Pay button's states (`.Button--disabled`, `.Button--loading`,
`.Button--paid`), plus the rule property `accentColor`. See
[Embedded SDK](/developers/08-embedded-sdk.md#rule-selectors).
- **Added.** `worthypayments.js`: the `walletButtons` option sets the Apple Pay and Google
Pay buttons' theme and type presets and their height (40-55 px). See
[Wallet buttons](/developers/08-embedded-sdk.md#wallet-buttons).
- **Fixed.** `worthypayments.js`: an Apple Pay button takes the theme it is given (it stayed
black), and the "Or pay with card" line under the wallet buttons uses your page's font.
- **Changed.** `worthypayments.js`: the Pay button inside the Payment Element is filled with
the `colorPrimary` you name (a `.Button` `backgroundColor` rule wins over it). It kept a
fixed green gradient whatever the appearance said. A theme's own primary does not repaint
it, and an element with no `colorPrimary` looks as before.
- **Added.** `worthypayments.js`: the `payButton` option. `{ label }` replaces "Pay" on the
idle button (1-40 characters), and `{ display: 'never' }` draws no button so your own calls
`element.submit()`. See [Payment Element](/developers/08b-payment-element.md).
- **Changed.** `worthypayments.js`: a `fontFamily` naming Inter, Roboto, Open Sans, Lato,
Montserrat or Poppins renders in that font, because the frame now serves them itself. It
fell back to the system font. Other families still fall back down your stack.
- **Fixed.** A payment intent answers the `reference` it was created with: on create and
update, and on retrieve to a secret or restricted key. A publishable key never receives
it, because the reference is your own order id. It was documented as echoed back and never
was.
- **Fixed.** A Payment Element on a page that another of your sites frames (for example
`shop.example.com` inside `dash.example.com`) renders the card form when every host in the
chain is on the key's allowed domains. Only the page directly above the form was allowed,
so the browser refused the frame. Firefox reports one level of framing only. See
[Payment Element](/developers/08b-payment-element.md).
- **Added.** A secret or restricted key can be limited to your server IP addresses and can
require signed requests (HMAC), both set in the dashboard when the key is created. The
signing secret is shown once. See [Authentication](/developers/02-authentication.md).
- **Fixed (docs).** `initiated_by: "merchant"` is accepted together with `recurring: true`;
only `"customer"` with `recurring: true` is refused. The guide said any `initiated_by` with
`recurring: true` was a `400`. See [Charges](/developers/03-charges.md#charge-a-saved-card).
- **Fixed (docs).** `POST /v1/customers` does not read `external_id`, and no other call sets
it, so `GET /v1/customers/external/{externalId}/state` finds no customer you created. Use
`GET /v1/customers/{id}/state`. The guides said to pass `external_id` on create. See
[Customers](/developers/18-customers.md).
- **Fixed (docs).** An email that already has a customer makes `POST /v1/customers` answer
`409` with `code: "already_exists"`, not `resource_already_exists`, and the reference no
longer describes the call as a find-or-create that returns the existing customer: only a
retry with the same body `idempotency_key` does. See [Customers](/developers/18-customers.md).
## 2026-10-17
- **Breaking.** `POST /v1/customer_sessions` (`customer`) and `GET /v1/customers/{id}/state`
and `GET /v1/customers/external/{externalId}/state` (`id`) answer the customer id as
`cus_`, as `GET /v1/customers` does; they answered the bare uuid. Requests accept
either form, as before. Still the bare uuid, unchanged: `customer` on charges (including
`GET /v1/customers/{id}/transactions`), `customer_id` on pre-dispute alerts, and webhook
`customerId`.
- **Fixed (docs).** The id table in [Versioning](/developers/20-versioning.md) gives the customer's id
as `cus_` and lists where the bare uuid still appears, and the
[Events](/developers/19-events.md) example shows the bare uuid.
- **Fixed (docs).** There is no per-minute refund budget: only the per-key request ceilings
apply, and `Retry-After` names the window that was hit. The guides said 5 refunds a minute
on a live key.
- **Fixed (docs).** Card-testing auto-block of an IP is an account setting per mode, not per
key, and server-side saved-card renewals are not exempt from it. See
[Integration hard rules](/developers/27-integration-hard-rules.md).
- **Fixed (docs).** An attempted 3-D Secure authentication shifts liability on Visa, Amex and
Discover (ECI 06), not on Mastercard (ECI 01). See [3-D Secure](/developers/07-3ds.md).
## 2026-10-16
- **Changed.** `POST /v1/payment_intents` checks `customer`. A customer this key's merchant
and mode do not hold is a `400` `invalid_request_error`, `code: resource_missing`,
`param: customer`, replayed on the same `Idempotency-Key`. A `CUS-…` number is accepted
and answered as `cus_…`. While the customer lookup is unavailable the create answers
`503`, which is not stored on the key.
- **Fixed.** A browser confirm with a saved card accepts the `pm_…` id that
`GET /v1/customers/{id}/payment_methods` returns. It answered `401`
`customer_session_invalid`.
- **Changed.** A payment intent's `customer` is always answered as `cus_` on create,
retrieve, update and confirm; some intents answered the bare uuid. A `customer` that is
not a string is a `400` `parameter_invalid`.
- **Fixed.** An intent created with `customer: "cus_…"` now saves the buyer's card to that
customer; the save used to fail silently. `GET /v1/customers/{id}/state` accepts the
`cus_…` id.
- **Fixed.** Hosted checkout pages use the appearance set in Settings → Checkout (logo,
colors, font, corner radius, favicon) and link the terms and privacy pages set in
Settings → Company.
## 2026-10-15
- **Fixed.** The API's CORS policy allows the `X-Customer-Session` header, so browsers load
a returning buyer's saved cards in the Payment Element and can pay with one. Browsers
dropped both requests before.
- **Fixed.** A refusal by a Payment Workflow rule (CVV, AVS or postal-code match, amount
limits, currency, anonymous IP) is a `402` `card_error`, `code: card_declined`, with a
`decline_code` such as `incorrect_cvc` or `amount_too_large`. `POST /v1/charges` answered
`500` and a payment intent confirm `503` `upstream_error`. See
[Payment Workflow rules](/developers/10-errors.md#payment-workflow-rules).
- **Fixed.** A second confirm sent while the first charge is still running answers
`status: processing` instead of `503`.
- **Fixed.** A fraud block on a 3-D Secure resubmit is the same `fraudulent` decline as on
the first submit. It was a `503`.
- **Changed.** The `customer` named on a payment intent owns the payment and the saved card,
whatever email the buyer types. The email used to decide.
- **Changed.** `worthypayments.js`: an Apple Pay / Google Pay decline reaches `onError` as
`type: "card_error"`, `code: "card_declined"`, with `declineCode` and the card form's
sentence in the element's `locale`. A wallet `onSuccess` receives the same shape as a
card payment.
- **Changed.** `worthypayments.js`: a card-form decline in `onError` has
`type: "card_error"`, as documented; it was `api_error`. `code` is still
`card_declined`, and other frame failures stay `api_error`.
- **Added.** `worthypayments.js`: when the intent's amount changed after the wallet buttons
were drawn, the wallet charges nothing and reports
`type: "invalid_request_error"`, `code: "amount_changed"`, then redraws the buttons with
the new total.
- **Changed.** `worthypayments.js`: a confirm waits at least 55 s (it was 15 s). If that
runs out, the SDK retrieves the intent, which never charges, and reports `succeeded` or
`processing`. A wallet buyer's email, name, phone and address, and billing `phone` and
`address.line2`, now reach the payment. Error messages no longer contain the client
secret.
- **Added.** A charge refused by the scored fraud screen is recorded as a failed charge. The
`400` from `POST /v1/charges` names it in `charge`; `GET /v1/charges/{id}` reads
`failure_code: fraudulent` and `outcome.type: blocked`; `charge.failed` fires with
`decline.declineOrigin: "PLATFORM"`. See [Fraud blocks](/developers/10-errors.md#fraud-blocks-fraud_blocked).
- **Fixed (docs).** The Payment Element guides describe `onLoadError`, the full decline
table, and matching webhooks on `detail.orderReference`. `update()` keeps the same client
secret.
- **Fixed (docs).** Sandbox test declines use an amount below 1.00 (the sandbox refuses
`4000000000000002`), and the sandbox Amex number is `341111111111111`.
- **Fixed (docs).** A `PATCH` on a closed intent is a `400` `failed_precondition`, not
`412`. A 3-D Secure resubmit with another amount is refused with `400`
`three_ds_amount_mismatch`. A key's allowed domains can be edited after it is created.
## 2026-10-14
- **Breaking.** A publishable key (`pk_…`) can no longer charge a saved card, capture or
void: `403 permission_denied`. Use a secret key. A publishable key is public, and a void
can become a full refund.
- **Fixed.** `POST /v1/charges` passes `billing_address`, `shipping_address`, the customer's
name and phone and `device.kount_session_id` to the payment, so AVS and fraud screening
see them. `GET /v1/charges/{id}` returns the stored `billing_details`. The charge is
attached to the customer with the given email.
- **Fixed.** A payment intent confirmed with a saved card records the card's brand, BIN and
last four on the charge.
- **Fixed.** A 3-D Secure resubmit is bound to the attempt that stepped up. A
`three_ds.session_id` from another payment is refused as an unknown session, and nothing
is charged.
## 2026-10-13
- **Changed.** `POST /v1/check` can deny with `BLOCKED_BYPASS_LINK` when a device or IP
links to a blocked customer. On charges, that link check obeys your Cascade switches. See
[Linked-account blocks](/developers/23-fraud-checks.md#linked-account-blocks).
## 2026-10-11
- **Added.** `recurring` on `POST /v1/charges` marks a saved-card charge as a subscription
renewal. It is accepted only with a saved payment method; otherwise it is a `400`
`parameter_invalid`. Saved-card charges are merchant-initiated and exempt from your 3-D
Secure policy. A customer flagged "Require 3-D Secure" declines with
`soft_decline_3ds`.
- **Fixed.** A customer session lists the customer's saved cards, and confirming with one
charges it. The list was empty and confirm answered `401` `customer_session_invalid`.
- **Fixed.** A saved card that needs 3-D Secure but cannot run it is declined with `402`
`soft_decline_3ds`. It answered `requires_action` with an empty `id` and session.
- **Added.** `card_save` on a payment intent confirm, on the retrieve of a succeeded intent
and in `onSuccess`, with the `pm_…` `payment_method` when the card was saved. See
[Did the card get saved?](/developers/08b-payment-element.md#did-the-card-get-saved).
- **Added.** `capture_before` and `cancellation_reason` on charges.
- **Changed.** An authorization nobody captured is canceled when the card network's window
ends (4 days 18 hours for a Visa merchant-initiated charge, 7 days otherwise). The charge
reads `canceled`, and `charge.expired` and `charge.canceled` fire. See
[Auth & capture](/developers/17-auth-capture.md).
- **Changed.** A delayed auto-capture runs at your delay or 6 hours before the authorization
expires, whichever comes first. A failed capture is retried with growing waits, up to an
hour apart.
- **Fixed.** `capture_method: "manual"` is honoured on a route whose Capture block is
Instant. Those charges were captured at once.
- **Fixed.** The buyer's IP address is read through our proxies. Fraud IP rules, your key's
IP allowlist and per-IP throttling saw a proxy address on every API, Payment Element and
hosted checkout request.
- **Fixed.** Payment Workflow routes are first-match. A catch-all "All other conditions →
Decline" route no longer declines a charge that an earlier route accepted.
## 2026-10-10
- **Breaking.** When the charge outcome is not yet known, a payment intent confirm answers
`status: processing`; it was `503` `upstream_error`. `worthypayments.js` now calls
`onSuccess({ status: "processing" })` instead of `onError` with `payment_unresolved`.
Check `status` before showing a success page, and fulfil from `charge.succeeded`.
Retrieving the intent resolves the outcome without charging.
- **Fixed.** Hosted checkout shows a completed payment as completed and opens 3-D Secure
when asked. It could report a completed payment as failed.
- **Changed.** Webhook endpoints that keep failing are throttled to one request a minute
after 10 failures in a row. They are auto-disabled only after 24 hours of continuous
failure, then probed hourly, and the account owner is emailed. Events are queued, not
dropped. See [Webhooks](/developers/16-webhooks.md#when-an-endpoint-keeps-failing).
- **Fixed.** On payment intents and hosted checkout, a fraud pre-screen block declines with
`fraudulent`, and an account that cannot take payments declines with
`merchant_unavailable`. Both were service errors. Hosted checkout charges the total the
Pay button shows after shipping and tax.
- **Fixed.** A void on a captured charge whose batch has not settled is a real void: the
charge reads `canceled`, not refunded. A settled charge is still refunded in full.
- **Fixed.** A refund the gateway refuses is a `400` `refund_declined`; it was `201` with
`status: failed`. A refused capture is a `402`, and a refused void a `400`
`void_declined`. `description` no longer triggers a duplicate-order refusal.
- **Changed.** There is no per-minute cap on charges, captures, confirms or refunds. It was
5 a minute on a live key and 50 on a test key. See [Rate limits](/developers/14-rate-limits.md).
- **Changed.** `worthypayments.js`: the Apple Pay / Google Pay sheet stays open until the
charge answers. `mountWallet`'s `onToken` may return a promise.
- **Fixed.** A payment the gateway itself refuses is an immediate `402` `card_declined` with
`decline_code` `duplicate_transaction`, `invalid_merchant`, `invalid_transaction` or
`processing_error`. It was a `502` with the charge left pending. Payment Element
declines carry `declineCode`.
- **Added.** `outcome` on every charge read: `network_status`, `type`, `seller_message`,
`risk_level`.
- **Changed.** `GET /v1/disputes/stats` counts dispute cases only. Pre-dispute alerts and
PayArc RDR resolutions, reversals and credits are left out. `win_rate` is `null` until a
case is won or lost. **Fixed (docs):** rates are whole percents.
- **Fixed.** `created[lte]`, `created[lt]` and `created=` list filters include rows from
within the boundary second.
## 2026-10-09
- **Fixed.** Retrying `POST /v1/charges` with the same `Idempotency-Key` after an unknown
outcome resolves the earlier attempt instead of charging again. While it is unresolved
the retry answers `409` `idempotency_in_progress`. An unknown outcome reads
`status: pending`; it was `unknown`.
- **Fixed.** After a decline, a new card on the same payment intent or checkout is charged.
It used to receive the first card's decline again.
## 2026-10-08
- **Fixed.** `GET /v1/customers/{id}/transactions` lists charges only, with real
`amount_refunded`, `disputed` and `livemode`. **Fixed (docs):** the refund errors are
documented. See [Refund a customer's past charges](/developers/06b-refund-past-charges.md).
## 2026-10-07
- **Added.** `card.wallet` is set on approved live sales synced from your NMI gateway once
NMI names the wallet, usually a few minutes after the sale. No webhook is sent when it
arrives.
## 2026-10-06
- **Added.** `card.wallet.card_last4` and `card.wallet.card_brand`, the buyer's own card
inside an Apple Pay / Google Pay wallet. Send them as `wallet.card_last4` /
`wallet.card_brand` on `POST /v1/charges`; `worthypayments.js` does this for you.
Webhooks carry `walletCardLast4` / `walletCardBrand`.
- **Added.** `walletType`, `walletCardLast4` and `walletCardBrand` are on a later manual
capture's `charge.succeeded` and on refunds of a wallet sale.
- **Fixed (docs).** A pre-dispute alert's `matchEvidence` card digits are the issuer's and
can differ from the sale's card (wallets, virtual cards). Reconcile on `transactionId`,
the ARN or the auth code.
## 2026-10-05
- **Added.** `card.wallet` (`{ "type": "apple_pay" }` or `{ "type": "google_pay" }`) on a
wallet charge we processed, and `walletType` on its webhooks.
## 2026-10-02
- **Added.** Dispute `status` `RELATED_CASE` and `parent_dispute_id`, for a PayArc related
case filed under its head dispute.
## 2026-01-01
Initial public contract for the Worthy Payments API:
- Charges, refunds, customers, payment methods, and payment intents
- Checkout links, products, and subscriptions
- Webhooks, 3-D Secure, and fraud checks
- Browser Payment Element (`worthypayments.js`) and `wp:` tokenization
Pin this date with the `API-Version` request header. Responses echo the resolved
version and a `Request-Id`.
The interactive reference is at [/developers/api-reference](/developers/api-reference).
========================================================================
========================================================================
## API ENDPOINT REFERENCE
========================================================================
Generated from docs/endpoints/E1-public-api.openapi.yaml, which is also what
/v1/openapi.json is generated from. Base host https://api.worthypayments.com; paths include
the /v1 prefix.
### GET /v1/openapi.json
This specification, served by the API itself.
The machine-readable contract, always matching the deployed edge: it is generated
from the canonical spec at build time and shipped inside the image, so it cannot
lag behind the code it describes.
**No authentication** — this is a public document, like a hosted `/openapi.json`
on any public API. Cached for 5 minutes.
Point a client generator here rather than at a copy you downloaded once.
Responses:
- 200 — The OpenAPI 3.1 document.
### GET /v1/docs
A hosted API reference page, served by the API itself.
An HTML reference rendered from the specification above, for reading the contract
straight off the API host. **No authentication.**
The full documentation — guides as well as endpoints — is at
`https://app.worthypayments.com/developers`.
Responses:
- 200 — An HTML page.
### GET /v1/ping
Authenticated ping — echoes key mode, type, and merchant.
The cheapest way to confirm a key works and to see what it actually is. It runs
under `RequireScopes("none")`, so a publishable key reaches it too, which makes it
usable as a browser-side smoke test. The response names the key's mode (`test` or
`live`), its type (`secret` or `publishable`) and the merchant account it resolves
to — enough to catch a key that points somewhere other than you expect before you
debug it as a charge failure.
Responses:
- 200 — OK
```json
{
"object": "ping",
"mode": "test",
"key_type": "secret",
"merchant_account_id": "acct_9fZ2sQ",
"api_version": "2026-01-01"
}
```
- 401 — connectrpc: unauthenticated — missing/invalid API key.
### POST /v1/charges
Create a charge (tokenized payment method only).
Proxies to `PaymentsOrchestrationService/Charge`. `capture_method: manual` (or
`capture: false`) creates an auth-only charge you later `/capture`. A real decline
is a **402** `card_error`. The `Idempotency-Key` header (or `idempotency_key` body
field) makes retries safe.
**The buyer's IP when your server sends the charge (`device_session`).** The IP a
charge carries for fraud scoring (per-IP velocity, the IP blocklist, the cascade) is
always the connection we see, never a body field. With a **publishable key** in the
buyer's browser that is the buyer. With a **secret or restricted key** it is your
server, so it is recorded as your server's address and never counted as a buyer IP
(one server address shared by every buyer of your store must not block them all at
once). To give such a charge the buyer's own IP and browser, call
`getDeviceSession()` in worthypayments.js when the buyer pays (it calls
`POST /v1/device_sessions` with your publishable key), post the `ds_…` id to your
server, and send it as `device_session`. Optional and additive: a charge without one
behaves as before. A session belongs to the account and mode that created it, lasts
30 minutes and may be sent on more than one charge in that time (an idempotent retry,
a second card after a decline). An unknown or expired session is a `400` with
`param: device_session`, and that refusal never consumes the `Idempotency-Key`: retry
with a fresh session, or without one. If our fraud service cannot read the session the
charge goes ahead without it rather than failing.
**Capture and the merchant's Payment Workflow.**
- **`capture_method: manual` is always honoured.** The charge comes back
`status: requires_capture`, `captured: false`, and stays that way until you
`/capture` or `/void` it.
- **`automatic` (the default) can be held by the merchant's route.** If the route the
charge takes has a Capture step set to **Manual**, the charge is authorized only and
the merchant captures it later. If the step is **Delayed**, the charge is authorized
only and captured automatically after the route's delay (1–168 hours). Either way
it comes back `status: authorized`, `captured: false`.
- **Read the outcome, not your request.** Check `captured` on the response before
treating the funds as settled.
**Declines, including gateway refusals.** Every decline is a **402** with `type:
card_error`, `code: card_declined` and a `decline_code`. An issuer decline carries the
issuer's reason (`insufficient_funds`, `do_not_honor`, …). When the gateway itself
refuses the request — the card was never sent to the card network — the charge is
declined at once (not left `pending`) and `decline_code` is one of
`duplicate_transaction` (the gateway already holds an identical recent payment),
`invalid_merchant`, `invalid_transaction` or `processing_error`, with the gateway's own
text in `message` (e.g. `Duplicate transaction REFID:3154897712` — quote the REFID to
gateway support). Do not retry `duplicate_transaction`: look the payment up with
`GET /v1/charges` first, it may already be taken. A real gateway fault is not a
decline: it stays a **502** `api_error` with generic wording.
**Payment Workflow rules are declines too.** A route that requires a CVV, AVS or
postal-code match, an amount range or a currency, or that blocks anonymous IPs,
refuses before the gateway with the same **402** `card_error` / `card_declined` and one
`decline_code`: `incorrect_cvc`, `incorrect_zip`, `incorrect_address`,
`amount_too_large`, `amount_too_small`, `currency_not_supported` or `fraudulent` (the
anonymous-IP rule), with a buyer-facing `message`. Nothing was charged; the same
`Idempotency-Key` replays the refusal, so retry a corrected request under a new key.
**Fraud blocks are not declines here.** A charge our fraud screening refuses is a
**400** `invalid_request_error`, `code: failed_precondition`, with a `message` that
starts `FRAUD_BLOCKED` (see the 400 below). A block by the scored fraud screen is
recorded as a failed charge, and the error carries **`charge`**, that charge's id
(`TXN-…`): `GET /v1/charges/{id}` reads it back as `status: failed`, `failure_code:
fraudulent`, `failure_message: "Your card was declined."`, `outcome.type: blocked`, and
your `charge.failed` / `transaction.failed` webhooks fire for it with
`decline.declineOrigin: "PLATFORM"`. It is refused before any 3-D Secure step-up, never
first sent through a challenge. A fraud block 400 **without** `charge` is otherwise
identical, and the card was never charged. It is either a fraud pre-screen (velocity)
block, or a rare scored block for which the payments service returned no charge id:
its record may not have been written, or it was written but the answer naming it was
lost. In that second case the failed charge exists and its `charge.failed` /
`transaction.failed` webhooks fire for a `TXN-…` this 400 never named. The 400 is
stored under the `Idempotency-Key` and replayed exactly for 24 h, `charge` (or its
absence) included, with no second charge and no second webhook. Do not retry a fraud
block under the same key: it replays the same refusal. A new key is a new attempt: it
is screened again, and a second refusal is a second failed charge.
**An unknown outcome is not stored as the key's answer.** When the payments service
cannot tell yet whether the card was charged, this answers the charge with `status:
pending` and releases the `Idempotency-Key` instead of storing that `pending`: a retry
with the SAME key asks again and gets the real outcome (the charge, or a 402), never a
frozen `pending`. Never retry it under a new key — that is a second charge if the first
lands. A pending ACH debit (accepted, in transit) is a real answer and is still
replayed for 24 h.
**PCI — accepted card / payment_method shapes.** This edge is outside the card-data
environment, so a raw card number is refused on *every* field with
`raw_pan_rejected` (400). Tokenize first. Each field is allowlisted to the grammar
its rail actually uses — two card rails are first-class:
| field | accepts |
|---|---|
| `card.number` + `card.cvv` (and `token` / `vault_token` / `cvv_token`) | Evervault tokens: `ev:` + at least 16 token characters. The number rail is handed to the gateway as the card number; CVV is a separate `ev:` token. |
| `card.wp_token` | one `wp:v1:` HPKE envelope that seals PAN, expiry and CVV together. Prefer this over `number`+`cvv` when present. There is **no** separate `wp:` CVV field — it rides inside the envelope. |
| `payment_method_id`, or a bare string `payment_method` | a saved payment-method id issued by this API. |
| `nmi_vault_id` | a gateway vault id issued by this API. |
| `wallet_token` | an Evervault wallet token. A wallet payment usually arrives as the `wallet` block instead. |
A value that is not a raw PAN but is not a recognized token either is rejected with
`pan_encryption_required` (card rail) or `parameter_invalid` (vault/wallet rails) —
the shapes are allowlisted, so an unrecognized token is never forwarded.
**A publishable key cannot charge a saved card.** `payment_method_id`, `nmi_vault_id`
and a bare string `payment_method` are refused for a `pk_` key with `403
permission_denied` (`param: payment_method`), before the card is looked up. The key is
public, so it would let anyone charge any of your saved cards. Charge a saved card from
your server with a secret key, or confirm a PaymentIntent with a customer session.
Fresh `ev:` / `wp:` cards and wallets are unchanged.
**3DS step-up.** A usable challenge returns `requires_action` with a nonempty
`next_action.three_ds.session_id`. Authentication can precede charge creation:
omit the charge `id` until authentication completes; preserve any real transaction id.
Resubmit the same amount, currency and instrument with `three_ds.session_id` and
the **same Idempotency-Key**. `requires_action` is not stored as a terminal response.
A saved card your server charges here without `initiated_by: customer` is
merchant-initiated and is never stepped up: it is charged without 3DS whatever asked
(route, risk band, Require-3DS customer, a fraud rule); a blocking rule still blocks it.
With the buyer paying (payment intent, hosted checkout, or a saved card charged here
with `initiated_by: customer`) a saved card is authenticated on its kept encrypted
number; one with none is declined `soft_decline_3ds` (402 `card_error/card_declined`)
with nothing charged, and the Payment Element (or your checkout) re-collects it.
Authenticated payments, ACH and wallets retain their exemptions.
Deterministic provider rejection is `processing_error`; temporary initiation or
session-storage failure is a sanitized 503 `api_error`. An unusable challenge is
a 502 `api_error` ("3-D Secure could not be started").
**Requesting 3DS.** `three_ds.request` asks for 3DS on this charge even when your
rules would not. Each value is the EMV 3DS challenge indicator the issuer receives:
| `three_ds.request` | 3DS runs | The issuer is told |
|---|---|---|
| `automatic` (default) | only when your fraud rules or the route's Require 3DS ask | a rule-driven challenge is mandated (04) |
| `any` | always | no challenge preference (01): frictionless whenever the issuer allows |
| `challenge` | always | a challenge is requested (03); the issuer may still authenticate frictionlessly |
| `challenge_mandated` | always | a challenge is mandated (04) |
A rule outranks a request: when your rules also ask for 3DS, the challenge is
mandated whatever `request` says, so `any` cannot soften a challenge your risk
rules demanded. The step-up then works exactly as above (`requires_action`, run the
challenge, resubmit with `three_ds.session_id` and the same key; repeating `request`
on the resubmit is fine). 3DS can only run on a card entered for this payment
(`card.number` as an `ev:` token): a request on a saved card, a gateway vault id, a
`wp:` envelope or a wallet is a 400 `parameter_invalid` (`param:
three_ds.request`). To authenticate a saved card, collect it again (the Payment
Element, or `tokenize`) and send it as `card.number`; a payment intent does this
for you. Omit it, or send `automatic`, to charge those as before. An
unknown key inside `three_ds` is refused too, so a misspelling cannot silently
charge without the 3DS you asked for.
**3D Secure threading.** If you ran 3DS in your own checkout (see `/v1/3ds/*`),
pass the session id as `three_ds.session_id`. The edge fetches that session's
**authoritative** result (`GetThreeDsSession`, cavv/eci from the provider — never a
browser value) and threads the real `cavv`, `eci`, `ds_trans_id`, protocol
`version` and `cardholder_auth` into the gateway request so liability shifts. A
session id that is not your merchant's is rejected upstream, never charged as
un-authenticated.
**A session that did not authenticate never charges.** Nothing reaches the card
network unless the session authenticated with a cryptogram (fully, or an
"attempted" authentication that carries one):
- **Failed** — 402 `card_error` / `card_declined`, `param: three_ds.session_id`.
`decline_code` is `three_ds_failed` (the cardholder failed or canceled the
challenge, or the issuer rejected it), `three_ds_card_not_enrolled` (the card
cannot be authenticated: charge it again without `three_ds` if you accept no
liability shift, or ask for another card) or `processing_error` (a 3DS
network fault: run a new session). `GET /v1/3ds/sessions/{id}` names the exact
`failure_reason`. Stored under the Idempotency-Key like any decline; a new
attempt is a new session and a new key.
- **Not finished** — 409 `three_ds_session_incomplete`: the challenge is still
open. Not stored; resubmit with the same key once it completes.
- **Result unreadable** — 503 `three_ds_result_unavailable`: the session
authenticated but its result could not be fetched just now. Not stored; retry
with the same key.
**Wallet Pay (Apple Pay / Google Pay).** Send a `wallet` block *instead of* a card
instrument — the wallet **is** the instrument:
```json
{
"amount": 1999, "currency": "usd",
"wallet": {
"type": "apple_pay",
"token": "ev:…", // encrypted network token (DPAN) from the wallet sheet
"exp_month": 12, "exp_year": 2029,
"cryptogram": "ev:…", // encrypted TAVV
"eci": "5", // as the wallet emitted it; we zero-pad for the gateway
"card_last4": "4973", // optional: the buyer's own card inside the wallet
"card_brand": "visa" // optional: that card's network
}
}
```
Prefer the Payment Element for wallets — it collects Apple Pay / Google Pay inside
our iframe (public `mountPaymentElement`). If you build a custom wallet path, forward
the wallet credential as the wallet provider emits it. Both `token` and `cryptogram`
are Evervault ciphertext and are decrypted in transit; a raw DPAN never reaches this
API.
`card_last4` / `card_brand` are optional: the last four digits and network of the
buyer's **own card** inside the wallet (the funding card), which the wallet sheet
states beside the device token (`mountWalletButton` puts them on the credential). The
token carries other digits; this is the number the buyer's bank, a receipt and a
dispute alert name, and the charge reports it back as `card.wallet.card_last4`. They
are never refused: anything other than exactly four digits, or a brand that is not a
plain network name, is dropped and the charge runs as it would without them. Neither
wallet hands out a BIN, so there is no `card_bin`.
Rules, each a **400** naming the offending field:
- `wallet` alongside `card` / `payment_method` is refused. The gateway would charge
the wallet and drop the card, so we will not guess which you meant.
- `wallet.cryptogram` is **required for Apple Pay** (always a network token).
Google Pay may omit it — that is the `PAN_ONLY` shape, charged as an ordinary
card sale. The SDK does not offer PAN_ONLY by default: it is not
SCA-satisfied, so a buyer your rules flag for 3DS would be declined after
completing the Google sheet with no way to recover inside it.
- `wallet` with `three_ds.session_id` is refused: the wallet cryptogram already
satisfies SCA, and the gateway rejects a wallet sale carrying 3DS fields.
No 3DS step-up ever runs on a wallet charge. Availability is per merchant and per
buyer device — check `/v1/wallet-config` before offering the button, and let the
SDK decide whether the device can actually pay.
The charge you get back — here, and on every later read — reports the wallet as
`card.wallet: { "type": "apple_pay", "card_last4": "4973", "card_brand": "visa" }` (or
`"google_pay"`), and the webhook raised when the charge is decided (`charge.succeeded`
on an automatic capture, `charge.authorized`, `charge.failed`) carries it as
`detail.walletType`, `detail.walletCardLast4` and `detail.walletCardBrand`. On a wallet
charge `card.last4` / `card.bin` belong to the device token, not the buyer's physical
card; `card.wallet.card_last4` is the physical card's, `null` when the wallet did not
state it.
Parameters:
- `Idempotency-Key` (header, required, string, uuid) — Required. A repeat of the same key replays the first response instead of charging twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body field is accepted instead of this header.
Request body (required):
Exactly one instrument: `card`, `payment_method` or `wallet`. Two of them is a 400, none of them is a 400 - the gateway would charge one and drop the other.
Fields:
- `amount` (integer, required, min 50) — Minor units. Below 50 is refused, and so is a value too large to represent exactly - JSON.parse would round it and charge a different number.
- `currency` (string, optional) — ISO 4217. A currency this account cannot settle is refused.
- `card` (object, optional) — Tokenized card. Never a raw PAN - every field is allowlisted to its rail's grammar.
- `card.number` (string, optional) — An `ev:` token, not a card number.
- `card.cvv` (string, optional) — A separate `ev:` token.
- `card.wp_token` (string, optional) — A `wp:v1:` HPKE envelope sealing PAN, expiry and CVV together. There is no separate `wp:` CVV.
- `card.exp_month` (optional) — Month, string or number.
- `card.exp_year` (optional) — Year, string or number.
- `card.name` (string, optional)
- `card.last4` (string, optional)
- `card.bin` (string, optional)
- `payment_method` (optional) — A saved payment-method id, a gateway vault id, or an object naming one.
- `recurring` (boolean, optional) — Set true for a subscription renewal using a saved payment method (payment_method.payment_method_id, pm_ or bare UUID). For saved-card charges, omitted or false means a subsequent unscheduled merchant-initiated transaction. True with a fresh card, wallet, wp_token or ACH is 400 parameter_invalid with param recurring. Included in the idempotency fingerprint; omitted and false are equivalent. Saved-card MITs are never sent to 3-D Secure: a customer flagged Require 3-D Secure, the 3DS risk band, a fraud rule asking for 3DS or the route's Attempt 3DS charges them without 3DS; a blocking fraud rule still blocks them. Each saved-card charge references the payment the card was saved from, when one was recorded. Installments are not supported.
- `initiated_by` (string, optional, one of customer | merchant) — Saved payment methods only. "customer" when the cardholder is paying now with a card they saved (on your checkout): the charge goes out as customer-initiated on a stored credential, and your 3-D Secure rules and `three_ds.request` apply to it as in the Payment Element. The card is authenticated on the encrypted copy kept when it was saved (requires_action; resubmit with three_ds.session_id under the same Idempotency-Key), or, with no such copy, declined 402 card_declined / soft_decline_3ds with nothing charged. "merchant" (the default) is a merchant-initiated transaction, never sent to 3-D Secure. 400 parameter_invalid with param initiated_by on a fresh card, wallet, wp_token or ACH, with recurring true and "customer" (a renewal is merchant-initiated), or for any other value. Included in the idempotency fingerprint; omitted and "merchant" are equivalent.
- `wallet` (object, optional) — Apple Pay / Google Pay credential. The wallet IS the instrument.
- `wallet.type` (string, optional, one of apple_pay | google_pay)
- `wallet.token` (string, optional) — Encrypted network token (`ev:`), never a raw DPAN.
- `wallet.exp_month` (optional) — Month, string or number.
- `wallet.exp_year` (optional) — Year, 2 or 4 digits, string or number.
- `wallet.cryptogram` (string, optional) — Encrypted TAVV (`ev:`). Required for Apple Pay.
- `wallet.eci` (string, optional) — As the wallet emitted it.
- `wallet.card_last4` (string, optional) — Optional. Last four digits of the buyer's own card inside the wallet (the funding card), as the sheet stated them. Not exactly four digits = dropped, never refused.
- `wallet.card_brand` (string, optional) — Optional. That card's network as the wallet named it (`visa`, `mastercard`). Dropped, never refused, when unusable.
- `customer` (object, optional)
- `customer.email` (string, optional)
- `customer.name` (string, optional)
- `customer.first_name` (string, optional)
- `customer.last_name` (string, optional)
- `customer.phone` (string, optional)
- `billing_address` (object, optional)
- `shipping_address` (object, optional)
- `device` (object, optional)
- `device.fingerprint` (string, optional)
- `device.kount_session_id` (string, optional)
- `device_session` (string, optional) — A device session id from worthypayments.js getDeviceSession() in the buyer's browser (POST /v1/device_sessions). The charge then carries the buyer's IP and user agent instead of your server's, and the session's fingerprint when `device.fingerprint` is absent. Optional; unknown or expired is a 400 with param device_session.
- `risk_decision_token` (string, optional) — Signed receipt from a prior POST /v1/check. Optional, but strictly verified when sent - a mismatch rejects the charge.
- `three_ds` (object, optional) — Only these two keys; any other key (a misspelling, a client-built `eci` / `cavv`) is a 400.
- `three_ds.session_id` (string, optional) — The edge reads the authoritative result from this session; browser-supplied cavv/eci are never trusted. A session that did not authenticate never charges.
- `three_ds.request` (string, optional, one of automatic | any | challenge | challenge_mandated) — Ask for 3DS on this charge: `any` (EMV 01, no challenge preference), `challenge` (03, challenge requested), `challenge_mandated` (04). `automatic` leaves it to your rules. Only on a card the buyer is paying with now: an `ev:` card entered for this payment, or a saved card with `initiated_by: customer`.
- `description` (string, optional)
- `capture_method` (string, optional, one of automatic | manual) — `manual` is always honoured. `automatic` (default) may still come back authorized-only when the merchant's Payment Workflow route holds it (Manual or Delayed capture) — see the operation description.
- `capture` (optional) — Boolean or string. `false` is the same as `capture_method: manual`.
- `metadata` (object, optional) — Your own key/value pairs. Stored on the charge and returned on the create response, GET /v1/charges/{id}, GET /v1/charges and the charge webhooks (data.object.detail.metadata). Up to 50 keys; a key is 1-40 characters without square brackets; a value is a string of up to 500 characters (a number or boolean is stored as its text; an empty value stores no key). Anything else is 400 parameter_invalid with param metadata. Cleared if the customer is deleted (GDPR), like the billing details.
- `idempotency_key` (string, optional) — Accepted instead of the `Idempotency-Key` header.
Example:
```json
{
"amount": 1999,
"currency": "usd",
"payment_method": {
"type": "card",
"token": "ev:card_tok_9c2b…",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030
},
"customer": {
"email": "buyer@example.com",
"name": "Ada Lovelace"
},
"description": "Order 1234",
"capture_method": "automatic",
"risk_decision_token": "cmR0MXxtYV8xfGNoa19hYmN8YWxsb3d8NDk5OXx1c2Q.9f8e7d…",
"metadata": {
"order_id": "1234"
}
}
```
Responses:
- 201 — Charge object (succeeded, requires_capture when auth-only, or requires_action for 3DS). The charge id is absent when authentication precedes charge creation.
```json
{
"id": "tx_3PkC9d2eZvKY",
"object": "charge",
"amount": 1999,
"currency": "usd",
"status": "succeeded",
"captured": true,
"payment_method": "pm_1Qabcd",
"customer": "4023ec41-ca5f-4153-859f-c30c960a599c",
"created": 1767268800,
"metadata": {
"order_id": "1234"
}
}
```
- 400 — `parameter_invalid` — amount < 50 cents, both/neither card & payment_method,
unsettleable currency, or a vault/wallet ref that is not a recognized token.
`raw_pan_rejected` — a raw card number was sent on any payment_method field.
`pan_encryption_required` — the card rail got a value that is neither `ev:` nor `wp:v1:`.
`parameter_invalid` with `param: three_ds.request` — an unknown value, or a request
on an instrument 3DS cannot run on (saved card, gateway vault id, `wp:` envelope,
wallet); with `param: three_ds.` — a key `three_ds` does not take.
Risk decision token (only when one was supplied):
`risk_decision_token_invalid` — bad signature, unknown format, or issued to a
different account.
`risk_decision_token_expired` — older than 15 minutes; run POST /v1/check again.
`risk_decision_token_amount_mismatch` — the charge amount or currency differs from
the evaluated one.
`risk_decision_token_card_mismatch` — a different card than the one evaluated.
`risk_decision_token_device_mismatch` — a different device fingerprint.
`risk_decision_token_reused` — already spent; a token is single use.
Device session (only when one was supplied), all with `param: device_session`:
`parameter_invalid` — not a `ds_…` id.
`resource_missing` — no such session for this account and mode (a test session on a
live key reads the same as an unknown id).
`device_session_expired` — older than 30 minutes; get a fresh one in the browser.
None of the three consumes the `Idempotency-Key`.
A `deny` verdict presented on a charge is a 402 `card_declined`, not a 400: you did
what the API asked and the answer was no.
Fraud block: `failed_precondition`, `message` starting `FRAUD_BLOCKED` (the
screening refused the charge before it reached the card network). `charge`
(string, optional, `TXN-` + 26 characters): the id of the failed charge the
refusal was recorded as. Every scored fraud block is recorded, so it carries one;
it is never on any other error. It is absent on a fraud pre-screen block, and on
the rare scored block for which the payments service returned no id: the record
may not have been written, or it was written and only its id was lost, in which
case the failed charge and its webhooks exist without this 400 naming them. Read
it back with `GET /v1/charges/{id}`. A same-key retry replays this 400 exactly for
24 h, with the same `charge` or the same absence of one.
- 402 — Card declined (`card_error` envelope). `decline_code` is the issuer's reason, or —
when the gateway refused the request before it reached the card network —
`duplicate_transaction`, `invalid_merchant`, `invalid_transaction` or
`processing_error` with the gateway's own text in `message`.
An unsupported instrument that needs 3DS returns `soft_decline_3ds` with
buyer-safe instructions to pay again with the card. A deterministic 3DS
provider rejection returns `processing_error` with a sanitized message.
A `three_ds.session_id` that did not authenticate returns `three_ds_failed`,
`three_ds_card_not_enrolled` or `processing_error` with `param:
three_ds.session_id`; nothing was charged.
- 403 — `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own. Add it in the dashboard under Settings > Integrations, on the NMI card, while the dashboard is in test mode; no NMI sandbox account yet: create one at https://guide.nmi.com/ (Create Sandbox Account). Nothing reached the gateway, and the refusal is not stored under the Idempotency-Key: retry the same request once the key is saved. Also `account_not_activated` (a live key on an account that has not completed onboarding) and `permission_denied` (missing scope).
- 409 — `idempotency_in_progress` — the same Idempotency-Key is already running.
Retry once the first request has returned; the completed response is then
replayed with `X-Idempotency-Replayed: true`. Raised whether the edge or
the payments service detects the concurrent key.
`three_ds_session_incomplete` — the `three_ds.session_id` challenge has not
finished. Nothing was charged and the key is not used up: resubmit with the
same Idempotency-Key once the challenge completes.
- 502 — api_error — 3-D Secure could not be started (missing usable session).
- 503 — api_error — 3-D Secure is temporarily unavailable; retry the payment with the same
Idempotency-Key. `three_ds_result_unavailable`: the `three_ds.session_id` session
authenticated but its result could not be fetched just now — nothing was charged,
retry with the same key.
### GET /v1/charges
List charges (keyset pagination), newest first.
Lists both immediate sales and authorizations. An authorization you have not
captured yet **is** a charge here and lists with `status: requires_capture`, the
same status `POST /v1/charges` returned for it — so a manual-capture integration
sees its charges. (Conventional semantics. The legacy API filtered to sales only, which
hid manual-capture charges permanently.) Refunds are not charges; list them at
`GET /v1/refunds`.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `status` (query, optional, string, one of succeeded | failed | requires_capture | authorized | requires_action | pending | canceled | refunded | PENDING | AUTHORIZED | APPROVED | CAPTURED | SETTLED | REFUNDED | VOIDED | DECLINED | ERROR) — Filter by status. Accepts the same values this API *returns* on a charge — `succeeded` covers every captured state (including a refunded charge, which did succeed), `failed` covers declines and errors. The raw processor statuses (`APPROVED`, `DECLINED`, `REFUNDED`, …) are also accepted for backwards compatibility. An unrecognized value is a 400, not a 500 — including an inherited JavaScript property name such as `constructor` or `__proto__`. `requires_action` is accepted as a filter (it resolves to the same in-flight rows as `pending`), but READS never emit it — see the note on the 200 below.
- `created` (query, optional, object) — Only charges created in this window, in Unix **seconds**, sent as bracketed keys: `created[gte]=1733600000&created[lt]=1733686400`. `created[gte]` and `created[lte]` are inclusive, `created[gt]` and `created[lt]` exclusive, and a bare `created=1733600000` matches that one second. A bound in milliseconds, or not a whole number of seconds, is a 400 `parameter_invalid_integer` naming it; a bound sent twice, or `created=` together with `created[...]`, is a 400 `parameter_invalid`. This is creation time, not last change: `GET /v1/events` is the change feed.
Responses:
- 200 — List of charges.
**A read never returns `requires_action`.** An in-flight charge — one whose
gateway outcome is not yet known, including the row pre-inserted before the
gateway call — reports `pending`, from BOTH `GET /v1/charges` and
`GET /v1/charges/:id`, and carries no `next_action`. `requires_action` with a
`next_action.three_ds.session_id` is emitted only by `POST /v1/charges`, which
is where an SDK gets the session to mount a challenge against.
**A failed row says why, as the retrieve does.** Each row carries the charge's
decline: `failure_code` (e.g. `insufficient_funds`, `duplicate_transaction`) and
`failure_message`, and its `outcome` names who answered — a charge the gateway
refused before it reached the card network reads `network_status:
not_sent_to_network`, `type: invalid` (see `GET /v1/charges/{id}`), a charge our fraud
screening blocked `not_sent_to_network` / `blocked` with `failure_code: fraudulent`,
an issuer decline `declined_by_network` / `issuer_declined`. A list row and a
retrieve of the same charge agree.
```json
{
"object": "list",
"data": [
{
"id": "tx_3PkC9d2eZvKY",
"object": "charge",
"amount": 1999,
"currency": "usd",
"status": "succeeded",
"paid": true,
"captured": true
},
{
"id": "tx_3PkC9d2eZvKZ",
"object": "charge",
"amount": 500,
"currency": "usd",
"status": "requires_capture",
"paid": true,
"captured": false
}
],
"has_more": false
}
```
- 400 — parameter_invalid — an unrecognized `status` value, a `created` bound sent twice, or `created=` together with `created[...]`. parameter_invalid_integer — a `created` bound that is not Unix seconds.
### POST /v1/device_sessions
Record the buyer's own connection for a charge your server sends (publishable key).
Called from the **buyer's browser** by worthypayments.js `getDeviceSession()`; you
normally never call it yourself. We record the IP this request comes from, its
`User-Agent` header and, optionally, the browser's fingerprint, and answer an opaque
`ds_…` id. Your server sends that id as `device_session` on `POST /v1/charges`, and the
charge then carries the buyer's IP and browser instead of your server's. Neither the IP
nor the user agent can be named in the body: an `ip` field is refused.
```
POST /v1/device_sessions Authorization: Bearer pk_..
{ "fingerprint": "8f3c1d2e" }
→ { "id": "ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3", "object": "device_session",
"livemode": false, "created": 1760000000, "expires_at": 1760001800 }
```
The session belongs to the account and mode (test or live) of the key that created it
and lasts 30 minutes; it may be presented on more than one charge in that time.
**Publishable keys only:** called with a secret or restricted key, the connection would
be your server again, so it is refused with `403`. The id holds no buyer data and is
useless without your secret key.
Request body:
Fields:
- `fingerprint` (string, optional) — The value worthypayments.js getFingerprint() returned. Optional.
Example:
```json
{
"fingerprint": "8f3c1d2e"
}
```
Responses:
- 201 — Device session.
```json
{
"id": "ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3",
"object": "device_session",
"livemode": false,
"created": 1760000000,
"expires_at": 1760001800
}
```
- 400 — `parameter_invalid` (`param: fingerprint`) — not an opaque id. `parameter_unknown` — any other body field, `ip` included.
- 403 — `permission_denied` — a secret or restricted key; create device sessions in the buyer's browser with your publishable key.
### GET /v1/charges/{id}/risk
Retrieve a charge's verification and fraud result.
AVS/CVV result codes plus the fraud score and verdict for a charge. These live on the
transaction row (02 stamps the fraud snapshot back from scoring), so this is a
projection of the same data as `GET /v1/charges/:id`. Requires `payments:read`.
`fraud_assessed` is the gate: when it is `false` the charge was never scored, and
`fraud_score` / `fraud_decision` / `fraud_case_id` are `null` (a 0 score and "not
scored" are different facts). AVS/CVV codes are emitted whenever the gateway returned
them, independent of scoring.
Parameters:
- `id` (path, required, string) — The charge whose risk evaluation you want.
Responses:
- 200 — charge_risk
```json
{
"id": "TXN-7b4c5802",
"object": "charge_risk",
"avs_result": "MATCH",
"cvv_result": "MATCH",
"avs_result_code": "Y",
"cvv_result_code": "M",
"fraud_assessed": true,
"fraud_score": 12,
"fraud_decision": "APPROVE",
"fraud_reason_codes": [],
"fraud_case_id": "case_9f2"
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `payments:read` scope (`permission_denied`).
- 404 — No such charge for this key's merchant and env-mode (`resource_missing`).
### GET /v1/charges/{id}
Retrieve a charge.
**Acquirer identity is stamped from Payment Workflow, not invented.** At
charge time the merchant's Payment Workflow picks which acquirer this sale
goes to (the routes on that road, the conditions of this payment). Those
processors all ride one NMI security key. The NMI `processor_id` on the sale
is that acquirer's **Connector code (`processor_id`)** / charge code. Public
`processor` is that same row's **Code** (`NMI`, `PAYARC`, `NUVEI`,
`MAVERICK`, or any later registry row). **Connector codes** map raw gateway
keys onto the acquirer. Those two admin fields are not this public
`processor` value.
The gateway's own answer also rides on the charge: `processor_transaction_id`,
`mid` when the row has one, the response and processor-response pairs, the
`avs_result` / `cvv_result` verdicts with their raw issuer letters, the statement
descriptor and the per-state timestamps. They are reflected, not computed, and
each is **omitted when the row has nothing in it** rather than emitted empty.
`response_code` / `response_message` are the gateway's own pair; a fresh
`POST /v1/charges` response reports them too once the gateway has answered (a
`pending` charge carries neither).
An authorized, uncaptured charge carries **`capture_before`**: the last moment it can
be captured. That is the card network's window, 4 days 18 hours for a Visa
merchant-initiated charge and 7 days otherwise (Stripe's
`payment_method_details.card.capture_before`). Past it the charge is canceled
(`status: canceled`, **`cancellation_reason: expired`**) and `charge.expired` fires
with `charge.canceled`. A void you requested carries no `cancellation_reason`.
**`metadata`** is the `metadata` you sent on `POST /v1/charges`, as stored; `{}` for a
charge that carried none, one made before it was stored, and a sale synced from your
gateway. `GET /v1/charges` returns it on every item too.
`outcome` says who answered. A `failed` charge the gateway refused before it reached
the card network reads `{ "network_status": "not_sent_to_network", "type": "invalid",
"seller_message": "The payment was not processed: ", "risk_level":
"normal" }` (`"The payment was not processed."` when the gateway gave no text). A
`failed` charge our fraud screening blocked before any gateway call (the payments
service's decline origin `PLATFORM`) reads `{ "network_status": "not_sent_to_network",
"type": "blocked", "seller_message": "This payment was blocked by fraud screening.",
"risk_level": "highest" }`, with `failure_code: fraudulent` and `failure_message: "Your
card was declined."`. That is the charge a fraud-blocked `POST /v1/charges` 400 names
in `charge`. Every other failure (an issuer decline, including an issuer `processing_error`) reads
`declined_by_network` / `issuer_declined`. `GET /v1/charges` reports the same
`outcome` for the same charge.
⚠️ `arn`, `rrn` and `settlement` are absent on a fresh charge by design: they come
from the acquirer days later. Read them back here after settlement.
AVS and CVV are the *gateway* answering. Your own engine's score, decision, reason
codes and case id are on `GET /v1/charges/{id}/risk`, behind `payments:read`.
`created` is the charge's own creation time (unix seconds) and is stable across
polls. An uncaptured authorization reports `requires_capture`, the same status
`POST /v1/charges` returned for it.
`card.wallet` is `{ "type": "apple_pay" }` or `{ "type": "google_pay" }` when the
charge was an Apple Pay / Google Pay payment: one we processed (a `wallet` block on
`POST /v1/charges`, the Payment Element or hosted checkout), or an approved live sale
we synced from your NMI gateway once NMI has named its wallet. A new synced sale is
tagged usually a few minutes after it syncs; one synced before this existed is tagged
when a one-off history check reaches it; no webhook is sent when it arrives. Until then,
and on every other charge (a declined or test-mode synced sale included), it is `null`:
we never guess a wallet. On a wallet charge `card.last4` and `card.bin` belong to the **device token**,
not the buyer's physical card — match a dispute or a receipt accordingly.
`GET /v1/charges` and `POST /v1/charges` report the same value.
`card.wallet.card_last4` / `card.wallet.card_brand` are the buyer's **own card** inside
the wallet — the last four and network the wallet sheet stated beside the device token,
and the number an issuer's dispute alert for this charge names. Both are `null` when
the wallet did not state them (a charge made before this was recorded, a custom
integration that does not forward them, or a sale synced from your gateway, which names
the wallet but not the card inside it); `card_brand` is also `null` for a network
outside the ones `card.brand` publishes. There is never a BIN: neither wallet hands
one out.
**`expand[]=attempts`** adds `attempts`: every processor this charge was tried on, in
the order Payment Workflow tried them, and how each attempt ended. A soft decline
(`200`, `201`, `202`, `260`, `05`, `51`, `61`, `65`, `N7`) is retried on the next
processor in the route's cascade; `203`, `204` and `250` never are, and a wallet or
`wp:` token charge gets one attempt. Without `expand[]` the charge is exactly as above.
Needs a secret or restricted key with `payments:read`; a publishable key never sees it.
```json
"attempts": [
{ "processor": "PAYARC", "processor_label": "Payarc Main", "outcome": "declined",
"reconciled": false, "decline_code": "insufficient_funds", "response_code": "202",
"cascade": false, "final": false, "attempted_at": "2026-10-19T09:15:00.120Z",
"duration_ms": 210 },
{ "processor": "MAVERICK", "processor_label": null, "outcome": "approved",
"reconciled": false, "decline_code": null, "response_code": "100",
"cascade": true, "final": true, "attempted_at": "2026-10-19T09:15:00.400Z",
"duration_ms": 95 }
]
```
Every key is present on every attempt; `null` means not known.
`processor` / `processor_label` name the processor the way the charge's own pair does.
`outcome` is `approved`, `declined`, `error` (the gateway refused the request or
answered something unusable), `timed_out` (no answer came back) or `pending` (still in
flight). `reconciled: true` marks an attempt whose own answer was lost and whose
approval was then confirmed by looking the sale up at the gateway; its `outcome` is
that approval. `decline_code` is the normalized decline (the vocabulary of
`failure_code`); `response_code` is the gateway's code for that attempt (the
vocabulary of the charge's `response_code`). `cascade` is true on every attempt after
the first. `final` marks the attempt that produced the charge's result. `attempted_at`
is when it was sent (RFC 3339) and `duration_ms` how long it took.
A charge with one attempt answers one entry. A charge our fraud screening blocked
answers `[]`: no processor saw it. On a charge made before attempts were published,
the attempts carry no `processor_label`, `attempted_at` or (except on the final
attempt) `decline_code`: those were not recorded then. A charge with no recorded
trail (made before 2026-07-31, or a sale
synced from your gateway) answers the single attempt its own record proves, with
`cascade: true` when it was reached by a cascade whose earlier attempts were not kept.
Parameters:
- `id` (path, required, string) — The charge id.
- `expand[]` (query, optional, array) — `attempts` adds the charge's `attempts` list (see below). Also read as `expand[0]` or `expand`; repeat it for more than one value. A value this endpoint cannot expand is ignored, not refused. Not offered on `GET /v1/charges`.
Responses:
- 200 — Charge
```json
{
"id": "tx_3PkC9d2eZvKY",
"object": "charge",
"amount": 1999,
"currency": "usd",
"status": "succeeded",
"paid": true,
"captured": true,
"created": 1767268800,
"processor": "NMI",
"processor_label": "NMI (Worthy)",
"mid": "9876543",
"processor_transaction_id": "10234567890",
"network_transaction_id": "024attmpn7fk3d0",
"auth_code": "123456",
"response_code": "100",
"response_message": "Approved",
"avs_result": "MATCH",
"avs_result_code": "Y",
"cvv_result": "MATCH",
"cvv_result_code": "M",
"statement_descriptor": "WORTHY* STORE",
"payment_method_type": "card",
"authorized_at": "2026-08-31T09:15:00Z",
"captured_at": "2026-08-31T09:15:01Z",
"card": {
"brand": "visa",
"last4": "4242",
"bin": "424242",
"wallet": null
}
}
```
- 404 — resource_missing — no such charge under this key's tenancy.
### POST /v1/charges/{id}/capture
Capture an authorized charge (Idempotency-Key required).
Optional partial `amount` (cents). Proxies to `PaymentsOrchestrationService/Capture` (atomic claim).
**Secret key only.** A publishable key is refused with `403 permission_denied`
before anything reaches upstream: it is public, and a buyer holding their own charge
id could otherwise capture a few cents of the authorization and release the rest.
Parameters:
- `id` (path, required, string) — The authorized charge to capture.
- `Idempotency-Key` (header, required, string) — Required. A repeat of the same key replays the first response instead of charging twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body field is accepted instead of this header.
Request body:
Send an empty object to capture the full authorized amount. `amount` is optional.
Fields:
- `amount` (integer, optional) — Minor units to capture. Omit for the full authorization.
- `currency` (string, optional) — ISO 4217. Omit to keep the charge currency.
Example:
```json
{
"amount": 1500,
"currency": "usd"
}
```
Responses:
- 201 — Charge
```json
{
"id": "tx_3PkC9d2eZvKY",
"object": "charge",
"amount": 1999,
"amount_captured": 1500,
"status": "succeeded",
"captured": true
}
```
- 400 — failed_precondition — charge not in an authorizable/capturable state.
- 402 — The gateway refused the capture (`card_error` / `card_declined` + `decline_code`). The authorization is still held; the same Idempotency-Key replays this answer.
- 403 — `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in test mode). Nothing reached the gateway; retry once the key is saved.
- 502 — Gateway error: the capture's outcome is not known. Retry with the same Idempotency-Key.
### POST /v1/charges/{id}/void
Void an authorized charge (Idempotency-Key required).
Cancels an authorization before it is captured, proxying to
`PaymentsOrchestrationService/Void`. Requires `payments:charge` and a key whose
exec-mode can charge. The `Idempotency-Key` is required here, so a retried void
resolves to the first answer instead of issuing a second one.
An authorization is released. A captured charge whose batch has not settled yet is
voided at the gateway too, so it never reaches the buyer's statement. A settled charge
(or one the gateway refuses to void because its batch closed) is reversed with a full
refund instead.
**Secret key only.** Because a void can become a full refund, a publishable key is
refused with `403 permission_denied`, exactly as it is on `POST /v1/refunds`. The
key is public and the buyer sees their own charge id, so allowing it would let a
buyer refund themselves after delivery.
Parameters:
- `id` (path, required, string) — The authorized charge to void.
- `Idempotency-Key` (header, required, string) — Required. A repeat of the same key replays the first response instead of charging twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body field is accepted instead of this header.
Responses:
- 201 — Charge (status becomes canceled/voided).
```json
{
"id": "tx_3PkC9d2eZvKY",
"object": "charge",
"status": "canceled"
}
```
- 400 — `void_declined` (+ `decline_code`): the gateway refused the void and the authorization is still held; the same Idempotency-Key replays this answer. `failed_precondition` (`INVALID_STATE_FOR_VOID`): the charge cannot be voided.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `payments:charge` scope, or is a publishable key (`permission_denied`). In test mode, `sandbox_key_required`: this account has no NMI sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in test mode).
- 404 — No such charge for this key's merchant and env-mode (`resource_missing`).
- 502 — Gateway error: the void's outcome is not known. Retry with the same Idempotency-Key.
### POST /v1/subscriptions
Start a subscription (server-only scope; Idempotency-Key required).
`customer` and `plan` are required. Unless the plan is trialing this takes the
opening charge on the customer's stored payment method, so it rides the same
money-path fence as `/v1/charges`: a FRAUD_ONLY key can never reach it, and a
publishable key can never hold `subscriptions:write`.
A retried POST with the same `Idempotency-Key` resolves to the SAME subscription
rather than starting a second one on the same card.
When the customer has no saved card, or when `payment_behavior` is
`default_incomplete`, the response is `status: incomplete` and
`latest_invoice.payment_intent.client_secret` for the existing Payment Element.
Completing that secret pays the opening invoice and activates the subscription.
Do not put `plan` or `interval` on the Element.
Proxies to `BillingService/CreateSubscription`.
Parameters:
- `Idempotency-Key` (header, required, string) — Required. A repeat of the same key replays the first response instead of charging twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body field is accepted instead of this header.
Request body (required):
Fields:
- `customer` (string, required) — Customer id to bill.
- `plan` (string, required) — Plan id. Plans are created in the dashboard.
- `quantity` (integer, optional) — Units of the plan. Defaults to 1 upstream.
- `coupon` (string, optional)
- `default_payment_method` (string, optional) — Saved payment-method id to charge.
- `collection_method` (string, optional)
- `payment_behavior` (string, optional, one of default_incomplete) — `default_incomplete` skips the opening charge even when a card is on file and returns `status: incomplete` plus `latest_invoice.payment_intent.client_secret` for the Payment Element. Omit it to charge a saved card immediately.
Example:
```json
{
"customer": "cus_9fK2pQ",
"plan": "plan_4mN7xB",
"payment_behavior": "default_incomplete"
}
```
Responses:
- 201 — The subscription.
```json
{
"id": "sub_7hT2wQ",
"object": "subscription",
"status": "incomplete",
"customer": "cus_9fK2pQ",
"plan": "plan_4mN7xB",
"quantity": 1,
"latest_invoice": {
"object": "invoice",
"payment_intent": {
"object": "payment_intent",
"status": "requires_payment_method",
"client_secret": "pi_abc_secret_sess_elem_1"
}
},
"cancel_at_period_end": false
}
```
- 400 — `idempotency_key_required` — this route requires an `Idempotency-Key`.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `subscriptions:write` scope (`permission_denied`).
### GET /v1/subscriptions
List subscriptions.
Lists subscriptions belonging to the key's merchant and env-mode, paged with
`limit` and `starting_after`. Reading is `subscriptions:read` — unlike create and
cancel, which move recurring money and are server-side `subscriptions:write`.
Narrow the page with `customer` for a single buyer, or with `status`.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `customer` (query, optional, string) — Return only subscriptions belonging to this customer id.
- `status` (query, optional, string) — Lifecycle filter — active, trialing, incomplete, past_due, paused, canceled, suspended.
Responses:
- 200 — A page of subscriptions.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `subscriptions:read` scope (`permission_denied`).
### PATCH /v1/subscriptions/{id}
Change the plan or the card, pause, or schedule a cancellation (server-only scope).
Send **exactly one** of `plan`, `paused`, `cancel_at_period_end`, or
`default_payment_method`. Sending none, or more than one, is refused with
`parameter_invalid` — each maps to a different billing operation and combining them
has no defined order.
- `plan` moves the subscription to another plan. Upstream prorates.
- `paused: true` pauses; `false` resumes. `resumes_at` (ISO-8601) is only
accepted alongside `paused: true` and schedules the automatic resume.
- `cancel_at_period_end: true` schedules the cancellation. **There is no undo** —
`false` is refused rather than accepted as a no-op, because accepting it would
read as "this un-cancelled" while the subscription still lapsed at period end.
- `default_payment_method` (`pm_…`) switches the card later renewals charge, with
no charge now. Typically the card a setup intent just saved: create a setup intent
for the subscription's customer, let the buyer save the new card with the Payment
Element, then send its `payment_method` here. The card must be an active saved card
of the subscription's own customer (another customer's card, or a detached one, is
404 `resource_missing`). A canceled subscription is refused. A `past_due`
subscription keeps its retry schedule, and the next retry charges the new card.
No `Idempotency-Key`: every operation here is naturally idempotent (pausing a
paused subscription, or moving to the plan already in force, is a no-op), and a
key would only add a way for a legitimate correction to be swallowed as a
duplicate.
Parameters:
- `id` (path, required, string) — The subscription to update.
Request body (required):
Exactly one of `plan`, `paused`, `cancel_at_period_end` or `default_payment_method`.
Fields:
- `plan` (string, optional) — Move to this plan id.
- `paused` (boolean, optional) — `true` pauses, `false` resumes.
- `resumes_at` (string, optional) — ISO-8601. Only with `paused: true`.
- `cancel_at_period_end` (boolean, optional) — `true` schedules cancellation at period end. `false` is refused.
- `default_payment_method` (string, optional) — `pm_…`: the card later renewals charge, switched with no charge now. An active saved card of the subscription's customer.
Example:
```json
{
"plan": "plan_pro_monthly"
}
```
Responses:
- 200 — The updated subscription.
- 400 — None, or more than one, of the four fields was set (`parameter_invalid`), or the change does not fit the subscription's status (a canceled subscription's card, for one).
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `subscriptions:write` scope (`permission_denied`).
- 404 — No such subscription, or (`default_payment_method`) no active saved card of that id for the subscription's customer.
### GET /v1/subscriptions/{id}
Retrieve a subscription.
Fetches one subscription, scoped to the key's merchant and env-mode. Requires
`subscriptions:read`, the same scope as the list.
Parameters:
- `id` (path, required, string) — The subscription id.
Responses:
- 200 — The subscription.
- 404 — No such subscription.
### DELETE /v1/subscriptions/{id}
Cancel a subscription (server-only scope).
Defaults to cancelling AT PERIOD END — the customer keeps the time they have
already paid for and the subscription simply stops renewing. Pass
`{"immediately": true}` to terminate mid-period instead.
Proxies to `BillingService/CancelSubscription`.
Parameters:
- `id` (path, required, string) — The subscription to cancel.
Request body:
Optional. Send no body to cancel at period end. `immediately` and `reason` are optional.
Fields:
- `immediately` (boolean, optional) — Cancel now instead of at period end.
- `reason` (string, optional)
Example:
```json
{
"immediately": false,
"reason": "customer_request"
}
```
Responses:
- 200 — The updated subscription.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `subscriptions:write` scope (`permission_denied`).
- 404 — No such subscription for this key's merchant and env-mode (`resource_missing`).
### POST /v1/plans
Create a subscription plan.
Proxies `BillingService/CreatePlan`. Same object the dashboard writes. Amount is
integer minor units. Interval is `day` / `week` / `month` / `year` (case-insensitive;
stored and listed as `DAY` / `WEEK` / `MONTH` / `YEAR`). There is no `minute` or
`second` interval. Requires `plans:write`.
Request body (required):
Fields:
- `name` (string, required)
- `description` (string, optional)
- `amount` (integer, required, min 50) — Minor units. Below 50 is refused (processor floor).
- `currency` (string, optional) — ISO 4217. Defaults to usd.
- `interval` (string, required, one of day | week | month | year | DAY | WEEK | MONTH | YEAR)
- `interval_count` (integer, optional, min 1)
- `trial_days` (integer, optional, min 0)
Example:
```json
{
"name": "Pro",
"amount": 1999,
"currency": "usd",
"interval": "month"
}
```
Responses:
- 201 — The created plan.
```json
{
"id": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"object": "plan",
"name": "Pro",
"amount": 1999,
"currency": "usd",
"interval": "MONTH",
"interval_count": 1,
"trial_days": 0,
"active": true,
"archived": false
}
```
- 400 — parameter_invalid / parameter_unknown.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key lacks `plans:write`.
### GET /v1/plans
List subscription plans.
The plans this merchant has defined, and the way to resolve the `plan` id that
`POST /v1/subscriptions` requires. Proxies `BillingService/ListPlans`. Cursor-paged
with `starting_after` (same shape as GET /v1/charges). Scoped to the key's merchant.
Requires `plans:read`. Create with `POST /v1/plans` or in the dashboard.
`amount` is in MINOR UNITS like every other money field on this API, and carries the
currency the plan was priced in.
Archived plans are hidden unless `include_archived=true`: an archived plan cannot
take a new subscription, so listing one by default would hand back an id that
`POST /v1/subscriptions` then refuses.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `active` (query, optional, boolean) — `true` returns only plans currently accepting subscriptions.
- `include_archived` (query, optional, boolean, default false) — Send `true` to include archived plans alongside live ones. Defaults to false.
- `interval` (query, optional, string, one of DAY | WEEK | MONTH | YEAR) — Return only plans that bill on this interval.
Responses:
- 200 — A page of plans.
```json
{
"object": "list",
"data": [
{
"id": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"object": "plan",
"name": "Pro monthly",
"description": "",
"amount": 2900,
"currency": "usd",
"interval": "MONTH",
"interval_count": 1,
"trial_days": 14,
"usage_type": "LICENSED",
"billing_scheme": "PER_UNIT",
"seat_limit": 0,
"metering_unit": null,
"active": true,
"archived": false,
"subscription_count": 42,
"created": 1788100472,
"updated": 1788100472
}
],
"has_more": false
}
```
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key lacks `plans:read`.
### GET /v1/plans/{id}
Fetch a plan.
Proxies `BillingService/GetPlan`. Scoped to the key's merchant. Requires
`plans:read`. The id accepts either the prefixed `plan_...` form the subscription
object shows or the bare uuid. A plan belonging to another merchant or the other
env mode answers `404`, not `403` — the id is not evidence the caller may see it.
Parameters:
- `id` (path, required, string) — The plan id.
Responses:
- 200 — The plan.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key lacks `plans:read`.
- 404 — resource_missing — no such plan for this merchant.
### GET /v1/invoices
List invoices.
Proxies `BillingService/ListInvoices`. Cursor-paged with `starting_after`.
Requires `invoices:read`. Amounts are integer minor units.
`hosted_invoice_url` is null on a draft and
`https://app.worthypayments.com/invoices/{id}/view?token=…` after finalize.
A successful CHARGE_AUTOMATICALLY renewal (including test bill-now)
writes a PAID row. Opening create does not mint an invoice in this
release. An empty `data` array is a successful list, not a missing route.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor: the last invoice id from the previous page.
- `customer` (query, optional, string) — Return only invoices for this customer id.
- `status` (query, optional, string) — Filter by status, e.g. `open`, `paid`, `void`.
Responses:
- 200 — A page of invoices.
```json
{
"object": "list",
"data": [],
"has_more": false
}
```
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key lacks `invoices:read`.
### GET /v1/invoices/{id}
Fetch an invoice.
Proxies `BillingService/GetInvoice`. Accepts `in_` or the bare uuid.
`hosted_invoice_url` is null on a draft; after finalize it is
`https://app.worthypayments.com/invoices/{id}/view?token=…`.
Parameters:
- `id` (path, required, string) — The invoice id.
Responses:
- 200 — The invoice.
```json
{
"id": "in_7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3",
"object": "invoice",
"status": "PAID",
"amount_due": 0,
"total": 1999,
"currency": "usd",
"hosted_invoice_url": "https://app.worthypayments.com/invoices/7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3/view?token=…"
}
```
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key lacks `invoices:read`.
- 404 — resource_missing — no such invoice for this merchant.
### POST /v1/invoices/{id}/send
Email this invoice (server-only `invoices:write`).
Proxies `BillingService/SendInvoice`. Accepts `in_` or the bare uuid.
OPEN or later only — a draft is refused (`finalize first`; finalize stays
on the dashboard). Re-send is allowed and emits `invoice.sent` again.
A publishable key is refused. There is no `invoice.payment_failed`
event; a declined card renewal is `subscription.past_due`.
Parameters:
- `id` (path, required, string) — The invoice to send.
Responses:
- 200 — The invoice after send.
```json
{
"id": "in_7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3",
"object": "invoice",
"status": "OPEN",
"amount_due": 1999,
"total": 1999,
"currency": "usd",
"hosted_invoice_url": "https://app.worthypayments.com/invoices/7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3/view?token=…"
}
```
- 400 — failed_precondition — draft (finalize first) or void.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key lacks `invoices:write`, or is a publishable key.
- 404 — resource_missing — no such invoice for this merchant.
### POST /v1/payment_intents
Create a payment intent (server-only scope; Idempotency-Key required).
Decide the amount on YOUR server, then hand the browser only the returned
`client_secret`. Nothing downstream ever reads an amount from the buyer's side.
```
POST /v1/payment_intents Authorization: Bearer sk_..
{ "amount": 4999, "currency": "usd" }
→ { "id": "pi_...", "client_secret": "pi_..._secret_...", "status": "requires_payment_method" }
```
`amount` is an integer in the smallest currency unit — `4999` is $49.99. A float
is rejected rather than rounded, because `49.99` would otherwise charge 49 cents.
The intent is stored as a checkout session, which is what makes the amount
authoritative: the charge path reads the total from the session, not the request.
`description` names the single ad-hoc line; `reference` is your own order or cart
id and is echoed back to you, never shown to the buyer.
**Metadata.** `metadata` is your own key/value pairs, with the same rules as
`POST /v1/charges`: up to 50 keys, keys 1–40 characters without square brackets,
string values up to 500 characters (a number or boolean is stored as its text), and
a key sent with an empty value is not stored. It is answered with `reference`: on
create and update, and on retrieve to secret and restricted keys only, never to the
publishable key the buyer's page holds. The charge the intent makes **copies it once,
when the charge is made**, so `GET /v1/charges/{id}`, the charge list and the
`charge.*` webhooks carry it; changing the intent's metadata later does not change a
charge already made. `checkout.session.completed` carries it too. A GDPR customer
deletion clears it. `PATCH /v1/payment_intents/{id}` merges into it.
**3DS.** `three_ds.request` is the same request `POST /v1/charges` takes (`automatic`
| `any` | `challenge` | `challenge_mandated`, EMV 3DS indicators 01 / 03 / 04), set
here by your server and applied to the charge the Payment Element confirms; the
Element runs the challenge itself. Your rules still outrank it: when they ask for
3DS the challenge is mandated whatever `request` says. It applies to a card the
buyer enters for this payment on the `ev:` rail (the Element's default), and to a
saved card: one saved with its encrypted number kept (every card saved from
2026-10-18) answers `requires_action` and is authenticated in place. A saved card with
no kept number (saved earlier, or imported) is declined `soft_decline_3ds` with
nothing charged (200 `requires_payment_method`) and the Element asks the buyer to
re-enter that card, which is then authenticated and charged (and, when it is the same
card, keeps its encrypted number for next time). The same happens when your rules ask
for 3DS on a saved card (the route's Attempt 3DS, the 3DS risk band, a customer marked
Require 3DS, another fraud rule): the buyer is at the Element, so a saved card is not
treated as merchant-initiated here; on a re-entry you receive `charge.failed` for the
declined attempt, then the events of the re-entered card. A wallet (its own SCA) or a `wp:`-sealed card pays without it. Every
read of the intent answers it back as `three_ds.request` (`automatic` when unset).
**Saving the card for later payments.** `setup_future_usage` (`off_session` or
`on_session`) saves the card this intent is paid with to `customer` once the payment is
approved, whatever the buyer's page sends: the Payment Element draws no "save this
card" box for such an intent, and `save_payment_method` on confirm changes nothing.
Use `off_session` when you will charge the card without the buyer present (a
subscription renewal), `on_session` when the buyer will pay with it again at your
checkout; both save the card the same way, and this payment is the card's initial
stored-credential transaction (customer-initiated), whose network id later
merchant-initiated charges on the card carry. `customer` is required with it, or the
create is a 400 `parameter_missing` with `param: customer`. The outcome is the
intent's `card_save`, as for a ticked box: a fresh card is `saved` (or `failed`); a
wallet payment (Apple Pay, tokenised Google Pay) cannot be saved and answers
`skipped` with `failure_code: card_not_saveable`, while the payment still succeeds; a
card the buyer picks from their saved cards is already saved and carries no
`card_save`. Tell the buyer at your checkout that the card is kept for later payments.
Every read answers it back as `setup_future_usage` (`null` when unset).
Parameters:
- `Idempotency-Key` (header, required, string) — Required. A repeat of the same key replays the first response instead of charging twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body field is accepted instead of this header.
Request body (required):
Fields:
- `amount` (integer, required, min 50) — Minor units. The price your server names; the browser never sends it. The floor is the same 50 cents `POST /v1/charges` enforces — an intent below it could be created and never paid. Sending none, or less, is a 400 `parameter_invalid`.
- `currency` (string, optional) — ISO 4217.
- `description` (string, optional)
- `customer` (string, optional) — Customer id the payment and any saved card belong to. It must be an existing customer of this key's merchant and mode: the `cus_` id `GET /v1/customers` returns, the bare uuid, or the `CUS-…` number. The intent answers with the `cus_` form, a `CUS-…` number included. Any other value (unknown, deleted, or another account's or mode's) is a 400 `resource_missing` with `param: customer`. Authoritative: the email the buyer types on confirm is only the receipt address and never moves the payment to another customer. Without it, the confirm email decides (find-or-create).
- `receipt_email` (string, optional) — Receipt destination. Supply this or `billing.email` on confirm, or the confirm fails `failed_precondition`.
- `reference` (string, optional) — Your own order or cart id.
- `metadata` (object, optional) — Your own key/value pairs, with the `POST /v1/charges` rules (see the operation description). Copied onto the charge when it is made. Answered to server keys only.
- `three_ds` (object, optional)
- `three_ds.request` (string, optional, one of automatic | any | challenge | challenge_mandated) — Ask for 3DS on the charge this intent makes (see the operation description). `three_ds.session_id` belongs to confirm, not create.
- `setup_future_usage` (string, optional, one of off_session | on_session) — Save the card this intent is paid with to `customer`, whatever the buyer chooses (see the operation description). Requires `customer`. `null` or omitted saves it only when the buyer ticks the save box.
- `idempotency_key` (string, optional) — Accepted instead of the `Idempotency-Key` header.
Example:
```json
{
"amount": 4999,
"currency": "usd",
"description": "Order",
"reference": "ord_1234",
"receipt_email": "jane@example.com",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"metadata": {
"order_id": "8812",
"order_number": "1234"
},
"setup_future_usage": "off_session",
"three_ds": {
"request": "challenge"
}
}
```
Responses:
- 201 — Payment intent, with the client secret for the browser.
```json
{
"id": "pi_111111112222433384445555",
"object": "payment_intent",
"amount": 4999,
"currency": "usd",
"status": "requires_payment_method",
"client_secret": "pi_111111112222433384445555_secret_tok_xxx",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"reference": "ord_1234",
"metadata": {
"order_id": "8812",
"order_number": "1234"
},
"latest_charge": null,
"three_ds": {
"request": "challenge"
},
"setup_future_usage": "off_session"
}
```
- 400 — parameter_invalid on `three_ds.request` (an unknown value) or `three_ds.` (a key create does not take, `session_id` included), parameter_invalid on `setup_future_usage` (not `off_session`, `on_session` or null), parameter_invalid with `param: metadata` (the `POST /v1/charges` metadata rules and messages), and parameter_missing with `param: customer` (`setup_future_usage` without a customer) are refused before the Idempotency-Key is used, so a corrected request may reuse the key. Otherwise: parameter_invalid (amount not a positive integer / currency not settleable / `customer` not a string), or resource_missing with `param: customer` when `customer` names no customer of this key's merchant and mode. The message is `No such customer: ''`. That 400 is stored on the Idempotency-Key like any other answer, so the same key replays it; correct the id and send a new key.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (a publishable key, or missing `payment_intents:create`) or mode_not_allowed (`fraud_only`).
- 409 — idempotency_in_progress — the same key is in flight.
- 503 — api_error / upstream_error: a service behind the API did not answer. With `customer` sent, that includes the customer check not answering in time, in which case no intent was created. A 503 is not stored on the key: retry with the same Idempotency-Key.
### GET /v1/payment_intents/{client_secret}
Retrieve a payment intent by its client secret (browser-safe).
Scope `none`, so the Element can poll it after confirming to learn the outcome. `reference` (your order id from create) and `metadata` (your key/value pairs, `{}` when none) are answered to secret and restricted keys only; a publishable key, which the buyer's page holds, never receives them.
**This is what you poll after a `processing` confirm.** When the intent's charge outcome is not known yet, this read first asks for it — **it never charges** — and answers the result: `succeeded` (with `latest_charge`), `requires_payment_method` with `last_payment_error.decline_code: payment_not_completed` (nothing was charged; the buyer can pay again on the same intent), `requires_action` with `next_action.three_ds.session_id` while the buyer is still in a 3-D Secure challenge (an intent awaiting its customer is never reported as not charged; a challenge left for 15 minutes counts as abandoned), or still `processing`. Poll every few seconds; any other intent is read as it stands, with nothing extra done.
**Warning:** Keyed on the **client secret**, not the `pi_...` id. The id is not secret — it appears in dashboards, logs and webhook payloads — so accepting it here would turn every leaked id into a readable intent. The secret is the only credential that addresses one intent, which is exactly the capability-URL contract the hosted checkout session already uses.
The response omits `client_secret`: the caller already holds it, and echoing it on every read only widens where it can be logged. Tenancy-scoped — an intent belonging to another merchant or the other env mode 404s.
**A `succeeded` intent carries its receipt and card-save result.** For a `succeeded` intent with a `latest_charge` (read as it stands, or just resolved by the refresh above), this read makes one transaction read under your key's merchant and mode and adds `charge` (the same named receipt subset confirm returns), `card_save` and, when the card was saved, the top-level `payment_method` — the same object confirm answered (see `PaymentIntentCardSave`). That read is bounded at 5 seconds: when it fails or does not answer in time, the intent is returned as it stands, still `succeeded`, without `charge`, `card_save` and `payment_method`. Every other status makes no extra call and carries none of the three.
Parameters:
- `client_secret` (path, required, string) — The `client_secret` from create.
Responses:
- 200 — Payment intent, without the client secret. A `succeeded` intent also carries `charge`, and `card_save` / `payment_method` when the payment asked to save the card.
- 401 — api_key_required / api_key_invalid.
- 404 — resource_missing — no such intent for this merchant (tenancy-scoped).
### PATCH /v1/payment_intents/{id}
Update a payment intent's amount, reference, customer, receipt email or metadata (server-only scope).
The cart changed. Write the new total onto the intent instead of minting a
second one and leaving the first to expire unpaid. Or what the intent says about
the payment changed: its `reference`, `customer`, `receipt_email` or `metadata`.
Send only what changes; everything you leave out stays as it is.
**Keyed on the `pi_...` id, NOT the client secret** — the opposite of every other
route on this resource, and deliberately so. The secret lives in the buyer's page,
so a secret-addressed re-price would hand the buyer the one decision the Payment
Element exists to keep on your server. The id is not a capability, and reaching
this route also needs the server-only scope `payment_intents:create` — the same
scope that named the first price, because naming a new one is the same authority.
**The client secret does not change.** An element already mounted against this
intent keeps working and simply sees the new amount on its next read; there is
nothing to re-mount. The response omits `client_secret` for that reason.
Only an unpaid intent moves. Once a charge is in flight or complete, or the intent
expired, the call fails (`failed_precondition`) rather than silently doing nothing,
so a late cart edit can never disagree with what the card was charged. The same
holds for every field here. Retrying is safe — the body states what the intent
should END UP as, so applying it twice lands on the same intent, which is why no
`Idempotency-Key` is required.
**The details.**
- `metadata` is **merged**, as on Stripe: a key you send is set, a key you send
with an empty value (`""`) is removed, and keys you do not send are kept. There
is no way to remove every key at once (`metadata: null` is a 400); send each
with `""`. The `POST /v1/charges` rules apply to what you send, and the intent
can hold at most 50 keys after the merge.
- `reference` and `receipt_email` replace what the intent holds; `""` or `null`
clears them.
- `customer` names a customer the same way create does, with the same check (an
existing customer of this key's merchant and mode). It replaces the intent's
customer; it cannot be removed (`null` or `""` is a 400). **It cannot change once
a payment attempt on the intent started**, even one that was declined: that
attempt, and any card it saved, belong to the customer the intent had then.
Sending the customer the intent already has is not a change and is accepted.
Changing it never moves a card already saved for any customer.
- A charge copies the intent's `reference` and `metadata` when it is made: an edit
after a declined attempt reaches the next attempt's charge, never one already made.
With an `amount` and details in one request, the details are applied first, then
the amount. Everything is validated before either, and a refused customer changes
nothing; if the amount then fails (the intent stopped being open in between) the
details stay changed, and sending the same request again finishes it. A request
with neither an `amount` nor a detail is refused on `amount`, as before.
Parameters:
- `id` (path, required, string) — The `pi_...` id from create (not the client secret).
Request body (required):
Fields:
- `amount` (integer, optional) — New total in minor units. `7500` is $75.00. Decimals are rejected. Required when `currency` or `description` is sent, or when no detail is.
- `currency` (string, optional) — ISO 4217. Omit to keep the intent's existing currency.
- `description` (string, optional) — Re-labels the line. Omitted, it falls back to "Payment".
- `reference` (string, optional) — Your own order or cart id. `""` or `null` clears it.
- `customer` (string, optional) — The customer the payment belongs to, as on create. Cannot be removed, and cannot change once a payment attempt on the intent started.
- `receipt_email` (string, optional) — Receipt destination. `""` or `null` clears it.
- `metadata` (object, optional) — Merged into the intent's metadata: a key with `""` is removed, keys not sent are kept. At most 50 keys after the merge.
Example:
```json
{
"amount": 7500,
"currency": "usd",
"description": "Order"
}
```
Responses:
- 200 — The updated payment intent, without the client secret, with its `reference` and `metadata`.
```json
{
"id": "pi_111111112222433384445555",
"object": "payment_intent",
"amount": 7500,
"currency": "usd",
"status": "requires_payment_method",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"receipt_email": "jane@example.com",
"reference": "ord_1234",
"metadata": {
"order_id": "8812"
},
"latest_charge": null
}
```
- 400 — parameter_invalid (amount not a positive integer / currency not settleable; a `reference`, `receipt_email` or `customer` that is not a string; a `customer` sent as `null` or `""`; `metadata` breaking the `POST /v1/charges` rules, `null` included, or holding more than 50 keys after the merge, with `param: metadata` and the message `metadata can have at most 50 keys.`); resource_missing with `param: customer` when `customer` names no customer of this key's merchant and mode (`No such customer: ''`); failed_precondition with `param: customer` when the customer would change after a payment attempt on the intent started; or failed_precondition: the intent is no longer open (a charge is in flight or done, or it expired), so nothing about it changes.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (a publishable key, or missing `payment_intents:create`) or mode_not_allowed (`fraud_only`).
- 404 — resource_missing — no such intent for this merchant (tenancy-scoped), a malformed `pi_` id, or a `pi_` id built from a checkout session that is not a payment intent (a payment link's, an invoice's, a subscription's first payment, a store cart): nothing about that session changes.
- 503 — api_error / upstream_error: a service behind the API did not answer. With `customer` sent, that includes the customer check not answering in time, in which case nothing changed. Send the same request again.
### POST /v1/payment_intents/{client_secret}/confirm
Confirm a payment intent from the browser.
Attach an instrument and charge the intent. Scope `none` — the capability is
the client secret, so this is the one write the browser makes.
**Confirming from your server.** The IP the payment is scored on is the connection we
see. With your publishable key in the buyer's browser that is the buyer. With a secret
or restricted key it is your server, so it is recorded as your server's address and never
counted as a buyer IP, as on `POST /v1/charges`. To give a server-side confirm the
buyer's own IP and browser, send `device_session` (from worthypayments.js
`getDeviceSession()` in the buyer's browser). An unknown or expired session is a `400`
with `param: device_session` and nothing is charged; if our fraud service cannot read it,
the confirm goes ahead without it.
**Important:** `amount` and `currency` are refused here, not ignored. The price was
fixed when your server created the intent. Silently dropping the field would
let an integrator who believes they are changing the price ship a checkout
that charges the original amount forever, with nothing to notice.
Pass **exactly one** instrument:
| Field | What it is |
|---|---|
| `card` | `ev:` field tokens (from `POST /test/tokens` in TEST, or an Evervault-encrypted card rail) |
| `wp_token` | a `wp:v1:` HPKE envelope |
| `payment_method` | a saved card id from a customer session |
| `wallet` | an Apple/Google Pay credential from the wallet sheet |
There are **four**, not three. The refusal you get for sending none names all four
(`pass \`card\`, \`wp_token\`, \`payment_method\`, or \`wallet\``), and a table that
listed three sent readers looking for a wallet route that does not exist.
Prefer `mountPaymentElement` for browser payments (SAQ A). Do not call non-public
SDK helpers such as `tokenize()` from the host page.
Two is refused rather than resolved: downstream picks a single instrument,
so the other would be dropped silently and the buyer could be billed
something the caller did not intend.
**3-D Secure.** A `requires_action` response carries
`next_action.three_ds.session_id`. Run the challenge, then call this endpoint
again with the **same instrument** plus `three_ds.session_id`. The instrument
is required on every confirm, including this one. `three_ds` takes only
`session_id` here: `three_ds.request` is your server's choice at create, and on
confirm it is a 400 `parameter_invalid` (`param: three_ds.request`).
You do not need to withhold it to be safe: substitution is prevented
server-side, where the 3DS session is bound to the amount it authenticated
and the charge key is derived from the session.
A decline is a normal outcome, not an error: you get `200` with
`status: "requires_payment_method"` and `last_payment_error`, and the buyer
can try another card against the same intent.
`last_payment_error` is `{ type: "card_error", code: "card_declined", decline_code,
message, network_status }`. `decline_code` is the normalized reason
(`insufficient_funds`, `duplicate_transaction`, `payment_not_completed`, …), absent
when none is known. `message` is the human text: the issuer's, or the gateway's own
words when the gateway refused the request (e.g. `Duplicate transaction REFID:…`).
`network_status` (a WorthyPayments extension, the vocabulary of a charge's
`outcome.network_status`) is `not_sent_to_network` when the gateway refused the request
before it reached the card network, `declined_by_network` when the issuer or network
declined it, and absent when not known (`payment_not_completed` included). Branch the
buyer's wording on it: `processing_error` is also an issuer answer, so the code alone
cannot say whether the card was ever seen.
**The four statuses this can return** (always `200`):
| `status` | What it means |
|---|---|
| `succeeded` | The money moved. Fulfil the order. |
| `requires_action` | 3-D Secure challenge — see above. |
| `requires_payment_method` | Declined. `last_payment_error` says why; the buyer can retry on the same intent. |
| `processing` | **The outcome is not known yet.** Poll the retrieve endpoint, never this one — see below. |
**The charge rides along.** When the confirm resolves to one, the response carries
`charge` — what a receipt shows. It is read back after the payment, so it is absent
— never partial, never wrong — when that read does not answer.
⛔ **A named subset, because this route is browser-reachable.** Confirm runs under
`RequireScopes("none")`: a publishable key calls it, and a publishable key is
public. The server charge carries more — the merchant's acquiring identity
(`mid`, `processor` = Admin → Acquirers → Code of the acquirer Payment Workflow
chose for this sale),
the references another system asks for later (`network_transaction_id`, `arn`,
`rrn`, `settlement`) and the gateway's `avs_result` / `cvv_result` verdicts. None
of that answers "what did the buyer just pay", and publishing verification
verdicts to a page the buyer can read is how a card-testing script learns which
addresses pass. Read them from `GET /v1/charges/{id}` with a secret key.
No instrument reference crosses either: no token, no vault id, no PAN, no CVV, and
no `card.bin`.
**Whether the card was saved.** When the buyer consented (`save_payment_method: true`),
a `succeeded` intent carries `card_save` (`PaymentIntentCardSave`) and, when the card
was saved, the top-level `payment_method` (`pm_…`). Both are read from the persisted
transaction in the same bounded read as `charge`, before the receipt is filtered, and
`charge` never carries them. The landed replay of an already-paid intent and a
confirm recovered after a forwarding fault answer the same value. They are
**absent** — never `null` — when the payment did not ask to save the card, when the
intent is not `succeeded` (a decline, a challenge, `processing`), and when the read
fails or does not answer within 5 seconds (then `charge` is absent too; the payment's
status and HTTP code are unchanged). `card_save.status: "pending"` means the save may
still be running or its outcome is unknown, and **it can stay pending**: no later read
of this intent moves it. A save that completes later sends the webhook
`payment_method.attached` only for a newly saved card, `payment_method.updated` when
it renewed a card the customer already had (same card, new expiry), and no event when
that card was already saved with the same expiry. So settle a lasting `pending` on your
server with `GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`);
never wait for `payment_method.attached`.
**The returned `pm_…` id is the saved card's public id.** Charge it from your server
with `POST /v1/charges` (`payment_method.payment_method_id`) or find it in
`GET /v1/customers/{id}/payment_methods`. A later browser confirm with a saved card
takes it as `payment_method`, together with the `X-Customer-Session` header of the
customer who owns it. Two places carry the same card's bare id, without the `pm_`
prefix: the customer-session list (`GET /v1/customer_sessions/payment_methods`, or the
SDK's `savedCards()`) and the `payment_method.attached` / `payment_method.updated` /
`payment_method.detached` webhooks. Confirm takes the bare id too.
`processing` is reachable and must be handled. It means the gateway did not tell us
in time whether the card was charged, or that an earlier confirm of this intent is
still charging (a buyer who pressed Pay again while the first one was in flight). Confirm answers it at once — it does not wait —
and it is final for this call. Do **not** fulfil on it, and do **not** poll this
endpoint: confirming is a payment attempt, not a status read. Learn the outcome by
polling `GET /v1/payment_intents/{client_secret}` every few seconds — that read asks
for the outcome without charging — or wait for the `charge.succeeded` webhook. The
intent moves to `succeeded`, or to `requires_payment_method` with
`last_payment_error.decline_code: payment_not_completed` when nothing was charged
(while a 3-D Secure challenge is still open it reads `requires_action`).
Treating `processing` as success is the one mistake that ships goods for money that
never arrived.
Parameters:
- `client_secret` (path, required, string) — The `client_secret` of the intent to confirm, taken from the create response.
Request body (required):
The amount is NOT here - it is fixed on the intent. Send exactly one instrument: none is a 400 `parameter_missing`, two is a 400 `parameter_invalid`. A `card` counts only when `card.number` is set.
Fields:
- `client_secret` (string, optional) — Echo of the path value; the path is authoritative.
- `card` (object, optional) — An `ev:` card token from tokenize().
- `card.number` (string, optional)
- `card.cvv` (string, optional)
- `card.exp_month` (string, optional)
- `card.exp_year` (string, optional)
- `card.name` (string, optional)
- `wp_token` (string, optional) — A `wp:` HPKE envelope, instead of the per-field `ev:` tokens.
- `payment_method` (string, optional) — A saved card, by its `pm_…` id (as `GET /v1/customers/{id}/payment_methods` and `card_save.payment_method` give it) or the bare id `GET /v1/customer_sessions/payment_methods` lists, sent with the `X-Customer-Session` header of the customer who owns it. A card the session customer does not own is refused with `customer_session_invalid`.
- `save_payment_method` (boolean, optional) — The buyer's explicit consent to save this card for future payments. Only `true` turns saving on; absent or `false` stores the card nowhere. Never pre-tick the control that sets it. The result is `card_save` on a `succeeded` intent.
- `device_session` (string, optional) — For a confirm sent from your server: a device session id from worthypayments.js getDeviceSession() in the buyer's browser, so the payment carries the buyer's IP and user agent instead of your server's. A browser confirm needs none. Unknown or expired is a 400 with param device_session.
- `wallet` (object, optional) — Apple Pay / Google Pay credential, as the SDK's `mountWalletButton` emits it (`kind`, `token`, `exp_month`, `exp_year`, `cryptogram`, `eci`, and the optional funding card `card_last4` / `card_brand`, which are dropped rather than refused when unusable).
- `billing` (object, optional) — Billing contact and address. `email` here also satisfies the confirm email precondition. The SDK fills it from the Element's `defaultValues` and, on an Apple Pay / Google Pay payment, from the contact the wallet sheet returned (`defaultValues` win).
- `billing.email` (string, optional)
- `billing.name` (string, optional)
- `billing.phone` (string, optional) — The buyer's phone, stored on the payment's customer step.
- `billing.address` (object, optional) — `line1`, `line2`, `city`, `state`, `postal_code`, `country` (ISO 3166-1 alpha-2). The address is what AVS checks.
Example:
```json
{
"card": {
"number": "ev:AQEB...",
"cvv": "ev:AQEB...",
"exp_month": "12",
"exp_year": "2029"
}
}
```
Responses:
- 200 — The intent's new state — succeeded, a challenge, a decline, or `processing` (the outcome is not known yet; poll the retrieve endpoint). A `succeeded` intent carries `card_save` / `payment_method` when the buyer asked to save the card.
- 400 — `parameter_unknown` (amount/currency sent) or `parameter_invalid` (zero or two instruments).
Device session (only when one was supplied), with `param: device_session`: `parameter_invalid` (not a `ds_…` id), `resource_missing` (no such session for this account and mode) or `device_session_expired` (older than 30 minutes). Nothing was charged; send the confirm again with a fresh session or without one.
`payment_method_unusable` — `This saved card can no longer be charged.` The owned card has no usable vault token. Nothing was charged; ask the buyer to choose another card.
`failed_precondition` — `customer email is required`. The intent carries no buyer email and one is needed before the charge: without it there is no receipt destination and nothing to resolve a customer by. **Nothing was charged** and the intent is still confirmable, so supply an email and send the request again.
Either end of the flow can supply it — `receipt_email` on `POST /v1/payment_intents`, or `billing.email` in this request's body. Sending neither is the only way to reach this error, and it is why `receipt_email` is optional on its own but not optional across the pair.
- 401 — api_key_required / api_key_invalid. For saved cards, customer_session_invalid (missing or invalid grant, another merchant/environment, or a card not owned by the session customer) / customer_session_expired (create a new customer session).
- 403 — `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in test mode), or create an NMI sandbox account first at https://guide.nmi.com/. Nothing reached the gateway.
- 404 — resource_missing — no such intent for this merchant.
### POST /v1/setup_intents
Create a setup intent — save and verify a card with no payment (server-only scope; Idempotency-Key required).
For a $0 trial sign-up, "add a payment method" in an account page, or a new card for a
subscription: your server opens a setup intent for an existing customer and hands the
browser its `client_secret`; the Payment Element mounted with that secret collects the
card and confirms it. Nothing is charged.
```
POST /v1/setup_intents x-api-key: sk_..
{ "customer": "cus_...", "usage": "off_session" }
→ { "id": "seti_...", "client_secret": "seti_..._secret_...", "status": "requires_payment_method" }
```
The confirm verifies the card with a $0 authorization (an NMI `validate`) and saves it
to `customer` in the same gateway request, flagged as the card's initial
stored-credential transaction (customer-initiated). Later merchant-initiated charges on
the saved card (`recurring: true`, `initiated_by: "merchant"` on `POST /v1/charges`)
refer to that verification. The card is saved exactly as a card saved with a payment
is: you receive `payment_method.attached` (or `payment_method.updated` when the customer
already had that card) and `setup_intent.succeeded`.
`usage` is what you will do with the card: `off_session` (the default) when you will
charge it without the buyer present, `on_session` when the buyer will pay with it again
at your checkout. Both save the card the same way. `three_ds.request` (`automatic` |
`any` | `challenge` | `challenge_mandated`) asks for 3-D Secure on the verification; the
Element runs the challenge. Your other 3DS rules apply when the saved card is later
charged.
Parameters:
- `Idempotency-Key` (header, required, string) — Required. A repeat of the same key replays the first response; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body field is accepted instead of this header.
Request body (required):
Fields:
- `customer` (string, required) — The customer the card is saved to: an existing customer of this key's merchant and mode (`cus_` or the bare uuid). An unknown one is a 400 `resource_missing` with `param: customer`.
- `usage` (string, optional, one of off_session | on_session) — What you will do with the saved card. Both save it the same way.
- `three_ds` (object, optional)
- `three_ds.request` (string, optional, one of automatic | any | challenge | challenge_mandated) — Ask for 3-D Secure on the verification. `three_ds.session_id` belongs to confirm.
- `idempotency_key` (string, optional) — Accepted instead of the `Idempotency-Key` header.
Example:
```json
{
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"usage": "off_session"
}
```
Responses:
- 201 — The setup intent, with the client secret for the browser.
```json
{
"id": "seti_111111112222433384445555aaaabbbb",
"object": "setup_intent",
"status": "requires_payment_method",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"usage": "off_session",
"payment_method": null,
"last_setup_error": null,
"cancellation_reason": null,
"three_ds": {
"request": "automatic"
},
"created": "2026-10-18T10:00:00Z",
"client_secret": "seti_111111112222433384445555aaaabbbb_secret_tok_xxx"
}
```
- 400 — parameter_missing (`customer`), parameter_invalid (`usage`, `three_ds.request`, or `three_ds.session_id` sent on create) or parameter_unknown (any other field) are refused before the Idempotency-Key is used. resource_missing with `param: customer` when the customer is not one of this key's merchant and mode.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (a publishable key, or missing `customers:write`) or mode_not_allowed (`fraud_only`).
- 409 — idempotency_in_progress — the same key is in flight.
### GET /v1/setup_intents
List setup intents, newest first (server-only scope).
Your server's view of the setup intents it created, in this key's merchant and mode,
newest first, optionally for one customer. Cursor-paged like `GET /v1/customers`: pass
`next_cursor` back as `starting_after` while `has_more` is true. No item carries a
`client_secret`.
```
GET /v1/setup_intents?customer=cus_...&limit=10 x-api-key: sk_..
→ { "object": "list", "data": [ { "id": "seti_...", "status": "canceled", ... } ],
"has_more": true, "next_cursor": "..." }
```
Parameters:
- `customer` (query, optional, string) — Only this customer's setup intents (`cus_` or the bare uuid).
- `limit` (query, optional, integer, default 10, max 100, min 1) — Page size, 1-100 (default 10).
- `starting_after` (query, optional, string) — The `next_cursor` of the previous page.
Responses:
- 200 — One page of setup intents.
```json
{
"object": "list",
"data": [
{
"id": "seti_111111112222433384445555aaaabbbb",
"object": "setup_intent",
"status": "canceled",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"usage": "off_session",
"payment_method": null,
"last_setup_error": null,
"cancellation_reason": "abandoned",
"three_ds": {
"request": "automatic"
},
"created": "2026-10-18T10:00:00Z"
}
],
"has_more": true,
"next_cursor": "eyJ2IjoiMjAyNi0xMC0xOFQxMDowMDowMC4wMDBaIiwiaWQiOiIxMTEifQ"
}
```
- 400 — parameter_invalid with `param: starting_after` — not a cursor this list issued.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (a publishable key, or missing `customers:read`).
### POST /v1/setup_intents/{id}/cancel
Cancel a setup intent (server-only scope).
Ends an open setup intent: it takes no further confirm (a confirm of it is 400
`setup_intent_unexpected_state`). Use it when the buyer abandons the form or you
created a duplicate. Keyed on the `seti_` id, never the client secret.
```
POST /v1/setup_intents/seti_.../cancel x-api-key: sk_..
{ "cancellation_reason": "abandoned" }
→ { "id": "seti_...", "status": "canceled", "cancellation_reason": "abandoned", ... }
```
- A canceled intent is answered as it stands; the first reason is kept.
- A `succeeded` intent cannot be canceled (400 `setup_intent_unexpected_state`): its
card is saved, so detach the card instead (`DELETE /v1/customers/{id}/payment_methods/{pmId}`).
- While a confirm of the intent is running it is 409 `setup_intent_in_progress`:
that attempt may be saving a card. Retrieve the intent, then cancel it if it is
still open.
Parameters:
- `id` (path, required, string) — The setup intent's `seti_` id. Anything else is a 404.
Request body:
Fields:
- `cancellation_reason` (string, optional, one of abandoned | duplicate | requested_by_customer) — Why it was canceled, answered back as `cancellation_reason`. Optional.
Example:
```json
{
"cancellation_reason": "abandoned"
}
```
Responses:
- 200 — The canceled setup intent.
```json
{
"id": "seti_111111112222433384445555aaaabbbb",
"object": "setup_intent",
"status": "canceled",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"usage": "off_session",
"payment_method": null,
"last_setup_error": null,
"cancellation_reason": "abandoned",
"three_ds": {
"request": "automatic"
},
"created": "2026-10-18T10:00:00Z"
}
```
- 400 — parameter_invalid (`cancellation_reason`), parameter_unknown (any other field), or setup_intent_unexpected_state (the intent succeeded: its card is saved).
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (a publishable key, or missing `customers:write`).
- 404 — resource_missing — no such setup intent for this merchant and mode.
- 409 — setup_intent_in_progress — a confirm of this intent is running.
### GET /v1/setup_intents/{client_secret}
Retrieve a setup intent by its client secret (browser-safe).
Scope `none`, so the Element (or your page) can read it with a publishable key. Keyed on the client secret, never the `seti_` id, and tenancy-scoped: another merchant's or the other mode's intent is a 404. The answer omits `client_secret`. A `succeeded` intent names the saved card in `payment_method`; read its brand, last four and expiry with `GET /v1/customers/{id}/payment_methods`. An intent whose last attempt failed is still `requires_payment_method` and carries `last_setup_error`.
Parameters:
- `client_secret` (path, required, string) — The `client_secret` from create.
Responses:
- 200 — The setup intent, without the client secret.
```json
{
"id": "seti_111111112222433384445555aaaabbbb",
"object": "setup_intent",
"status": "succeeded",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"usage": "off_session",
"payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
"last_setup_error": null,
"cancellation_reason": null,
"three_ds": {
"request": "automatic"
},
"created": "2026-10-18T10:00:00Z"
}
```
- 401 — api_key_required / api_key_invalid.
- 404 — resource_missing — no such setup intent for this merchant and mode.
### POST /v1/setup_intents/{client_secret}/confirm
Confirm a setup intent with the buyer's card (browser-safe; the Payment Element calls this).
The Payment Element mounted with the setup intent's `client_secret` calls this for you;
call it yourself only with your own Evervault-encrypted fields. The card must be
Evervault-encrypted (`ev:`): a raw number or a `wp:` envelope is refused. The confirm
runs one $0 verification that saves the card to the intent's customer.
- **Saved:** 200 `succeeded` with `payment_method` (`pm_…`). Confirming a succeeded
intent again answers the same card and verifies nothing.
- **Declined or not verifiable:** 200 `requires_payment_method` with
`last_setup_error` — `code` `card_declined` (with the issuer's `decline_code`) or
`processing_error` (the card could not be read or verified right now). The buyer can
try again on the same intent; after five failed attempts it accepts no more (400
`setup_intent_attempts_exceeded`) and you create a new one. You receive
`setup_intent.setup_failed`.
- **Refused by fraud screening:** before the card is verified, the attempt meets the
same fraud screening as a payment: your blocked customers and cards, card-testing
detection, and your velocity rules (per IP, email, card). A refusal is a declined
attempt, as a payment's fraud block is: 200 `requires_payment_method` with
`last_setup_error` `card_declined` / `decline_code` `fraudulent`, counted toward the
five, and nothing is sent to the card network. Which rule fired is never answered.
- **Canceled:** a canceled intent takes no card (400 `setup_intent_unexpected_state`).
- **3-D Secure:** when the intent was created with a `three_ds.request` other than
`automatic`, the first confirm answers 200 `requires_action` with
`next_action.three_ds.session_id`; the Element runs the challenge and confirms again
with the same card and `three_ds.session_id`. A session that did not authenticate is
402 `card_error` / `card_declined` with `decline_code` `three_ds_failed` (or
`three_ds_card_not_enrolled`), and nothing is verified.
Parameters:
- `client_secret` (path, required, string) — The `client_secret` from create.
Request body (required):
Fields:
- `card` (object, required)
- `card.number` (string, required) — Evervault-encrypted card number (`ev:…`).
- `card.cvv` (string, optional) — Evervault-encrypted CVC (`ev:…`); verified by the issuer when sent, never stored.
- `card.exp_month` (string, required) — 1-12.
- `card.exp_year` (string, required) — Two or four digits.
- `card.name` (string, optional)
- `billing` (object, optional) — The billing address is sent with the verification for AVS; it is not stored.
- `billing.address` (object, optional)
- `billing.address.line1` (string, optional)
- `billing.address.line2` (string, optional)
- `billing.address.city` (string, optional)
- `billing.address.state` (string, optional)
- `billing.address.postal_code` (string, optional)
- `billing.address.country` (string, optional)
- `three_ds` (object, optional)
- `three_ds.session_id` (string, optional) — The 3-D Secure session the buyer just finished (the resubmit only).
- `device` (object, optional) — Accepted and ignored (the SDK's card form sends it).
- `device_session` (string, optional) — For a confirm sent from your server: a device session id from worthypayments.js getDeviceSession(), so fraud screening sees the buyer's IP. With a secret or restricted key and no session, no IP is screened (your server's address is never counted as a buyer's). Unknown or expired is a 400 with param device_session.
Example:
```json
{
"card": {
"number": "ev:Tk9D:number:QUJDREVGR0hJSktMTU5PUA==:$",
"cvv": "ev:Tk9D:cvc:MTIz:$",
"exp_month": "12",
"exp_year": "2034"
}
}
```
Responses:
- 200 — The setup intent after the attempt.
- 400 — parameter_missing (`card`), parameter_invalid (`card.exp_month`, `card.exp_year`, `three_ds.request` on confirm), pan_encryption_required (`card.number` / `card.cvv` not `ev:`), parameter_unknown (any other field), setup_intent_attempts_exceeded (five failed attempts: create a new setup intent), setup_intent_unexpected_state (the intent is canceled), or, with `param: device_session`, parameter_invalid / resource_missing / device_session_expired (nothing was verified).
- 401 — api_key_required / api_key_invalid.
- 402 — card_error / card_declined with decline_code three_ds_failed or three_ds_card_not_enrolled — the 3-D Secure session did not authenticate. Nothing was verified.
- 403 — `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in test mode), or create an NMI sandbox account first at https://guide.nmi.com/. Nothing was verified or saved, the attempt is not counted, and the intent stays confirmable.
- 404 — resource_missing — no such setup intent for this merchant and mode.
- 409 — setup_intent_in_progress — another confirm of this intent is running (retrieve it to learn the outcome), or three_ds_session_incomplete — the challenge is still open.
### POST /v1/customer_sessions
Mint a scoped grant to read one customer's saved cards (server-only).
Your server names the customer; the browser gets a token that can read that
customer's payment methods and nothing else. Pass it to the Element as
`customerSessionSecret`.
```
POST /v1/customer_sessions Authorization: Bearer sk_..
{ "customer": "cus_..." }
→ { "object": "customer_session", "customer": "cus_...",
"client_secret": "...", "expires_at": 1760000000 }
```
Expires in 30 minutes. It grants **read only**, on **one** customer: it cannot
charge, cannot modify anything, and cannot name a different customer. That is why
`customers:read` itself stays closed to publishable keys — that scope lists every
customer you have.
Request body (required):
Fields:
- `customer` (string, required) — The one customer this grant may read: `cus_` or the bare uuid. Missing or blank is a 400.
Example:
```json
{
"customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8"
}
```
Responses:
- 201 — Customer session.
- 400 — parameter_invalid (customer missing) / customer_sessions_unavailable (not configured).
- 403 — permission_denied — a publishable key, or missing `customer_sessions:create`.
### GET /v1/customer_sessions/payment_methods
List the session customer's saved cards (browser-safe).
Scope `none`: the grant is the `X-Customer-Session` header, not the API key.
Two independent checks must pass. The signature proves the token is one we minted; a tenancy check then proves it belongs to the calling key — without that second step a merchant could replay another merchant's valid token and read their customer's cards.
Returns brand, last4 and expiry only. Vault ids, `ev:`/`wp:` tokens and fingerprints are deliberately absent: the browser selects a card by OUR id, and resolving it to an instrument happens server-side on the charge.
`id` is the bare payment-method id, without the `pm_` prefix that `GET /v1/customers/{id}/payment_methods` and a payment intent's `card_save.payment_method` carry. Pass it as confirm's `payment_method`; confirm takes the `pm_…` form of the same card too.
Parameters:
- `X-Customer-Session` (header, required, string) — The `client_secret` returned by `POST /v1/customer_sessions`.
Responses:
- 200 — A list of payment methods.
```json
{
"object": "list",
"data": [
{
"id": "6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
"object": "payment_method",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2029
}
}
]
}
```
- 401 — customer_session_invalid (bad, forged, or another tenant's) / customer_session_expired.
### POST /v1/refunds
Create a refund (server-only scope; Idempotency-Key required).
`charge` is required. Omit `amount` for a full refund of what remains, or pass cents for
a partial.
- **One charge, one card.** Each request refunds exactly one charge, back to the card
that charge was paid with. There is no bulk refund and no refund by customer.
- **Partial refunds.** You may refund one charge several times until its captured amount
is used up.
- **Gateway-synced charges.** A charge synced from your gateway is refundable like one
made through the API.
- **Rate limit.** No per-minute refund budget; only the general per-key request ceiling
applies. On a `429 rate_limit_exceeded`, back off by `Retry-After`.
- **Refunding past spending instead of a payout.** See the **Refund a customer's past
charges** guide.
**Refused before any money moves** (`400`; the message names the reason):
- `ALREADY_FULLY_REFUNDED` (`failed_precondition`): nothing is left to refund.
- `REFUND_EXCEEDS_REMAINING` (`invalid_argument`): `amount` is more than what remains.
Platform refunds and refunds made in your gateway's own portal both count.
- `TRANSACTION_DISPUTED` (`failed_precondition`): a chargeback or a matched pre-dispute
alert is open on the charge. The cardholder may already be getting the money back, so
a refund on top would pay twice.
- `REFUND_WINDOW_EXPIRED` (`failed_precondition`): the charge is more than 180 days old.
For a gateway-synced charge, that means 180 days from the gateway's own date.
- `INVALID_STATE_FOR_REFUND` (`failed_precondition`): the charge never captured any
money.
- `REFUND_CURRENCY_MISMATCH` (`invalid_argument`): `currency` is not the charge's.
Proxies to `PaymentsOrchestrationService/Refund`.
Parameters:
- `Idempotency-Key` (header, required, string) — Required, as a header. A repeat of the same key replays the first response instead of refunding twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. Derive it from what you are refunding (e.g. `payout-1042:TXN-…`), not from the moment you send it.
Request body (required):
Fields:
- `charge` (string, required) — The charge to refund (its `id`). Missing is a 400.
- `amount` (integer, optional) — Minor units (cents), a whole number ≥ 1. Omit for a full refund of what remains.
- `currency` (string, optional) — ISO 4217, read only with `amount`. When omitted it defaults to `usd`, so a partial refund of a non-USD charge must pass the charge's own currency. A different currency is refused (`REFUND_CURRENCY_MISMATCH`).
- `reason` (string, optional, one of requested_by_customer | duplicate | fraudulent) — Optional. Any other value is a `400 parameter_invalid`.
Example:
```json
{
"charge": "tx_3PkC9d2eZvKY",
"amount": 500,
"currency": "usd",
"reason": "requested_by_customer"
}
```
Responses:
- 201 — Refund object
```json
{
"id": "re_tx_3PkC9d2eZvKY",
"object": "refund",
"amount": 500,
"currency": "usd",
"charge": "tx_3PkC9d2eZvKY",
"status": "succeeded",
"reason": "requested_by_customer"
}
```
- 400 — `idempotency_key_required` (no `Idempotency-Key` header; a body field does not count); `parameter_invalid` (no `charge`, an `amount` that is not a whole number ≥ 1, an unknown `reason`); or a refusal named in the message: `ALREADY_FULLY_REFUNDED`, `REFUND_EXCEEDS_REMAINING`, `TRANSACTION_DISPUTED`, `REFUND_WINDOW_EXPIRED`, `INVALID_STATE_FOR_REFUND`, `REFUND_CURRENCY_MISMATCH` (see the description); or `refund_declined` (+ `decline_code`): the gateway refused the refund, no money moved, and the same Idempotency-Key replays this answer.
- 403 — `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in test mode). No money moved; retry the same request once the key is saved.
- 404 — `resource_missing` — no such charge for this key (`TRANSACTION_NOT_FOUND`).
- 409 — idempotency_in_progress.
- 429 — `rate_limit_exceeded` — the general per-key request ceiling; back off by `Retry-After`.
### GET /v1/refunds
List refunds (type=REFUND; optional parent charge filter).
Lists refunds for the key's merchant and env-mode, paged with `limit` and
`starting_after`.
★ The scope is `payments:read`, not a refunds-specific one. A key granted only
`refunds:create` can issue refunds and will still be refused here — worth checking
first if this returns 403 on a key that demonstrably refunds.
Parameters:
- `charge` (query, optional, string) — Parent charge id filter. It lists the refunds made through the API. A refund made in your gateway's own portal points at the gateway's id and is not listed under the charge's `id`. Read the charge's `amount_refunded` for the full total.
- `limit` (query, optional, integer) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
Responses:
- 200 — List of refunds
```json
{
"object": "list",
"data": [
{
"id": "re_tx_3PkC9d2eZvKY",
"object": "refund",
"amount": 500,
"currency": "usd",
"charge": "tx_3PkC9d2eZvKY",
"status": "succeeded"
}
],
"has_more": false
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `payments:read` scope (`permission_denied`).
### GET /v1/refunds/{id}
Get refund by id (type=REFUND under tenancy).
Fetches one refund, scoped to the key's merchant and env-mode. Takes the same
`payments:read` scope as the list, not `refunds:create`.
Parameters:
- `id` (path, required, string) — The refund id.
Responses:
- 200 — Refund
```json
{
"id": "re_tx_3PkC9d2eZvKY",
"object": "refund",
"amount": 500,
"currency": "usd",
"charge": "tx_3PkC9d2eZvKY",
"status": "succeeded"
}
```
- 404 — resource_missing.
### GET /v1/pre_dispute_alerts
List pre-dispute alerts changed since a timestamp (polling).
The pull side of the `pre_dispute_alert.*` webhooks. Use it to catch up after a missed
delivery, or instead of running an endpoint at all.
The object here is a **superset** of the webhook payload: it carries the same lifecycle
fields (`state`, `amount`, `external_id`, `reason_code`) and adds the match evidence,
the sub-merchant MID, the processor and the linked sale's gateway id. Reconcile the two
on `id` — but do not assume the field sets are identical.
**How to poll.** Keep the largest `updated_at` you have seen and pass it back as
`updated_since`. Page with `starting_after` until `has_more` is false, then sleep.
Ordering is by `updated_at` ascending, so an alert updated while you are paging moves
to the END of the sequence rather than shifting the rows ahead of it. You may therefore
see the same alert twice — dedupe on `(id, updated_at)`, exactly as for the webhooks —
but you cannot miss one.
An `updated_since` that is neither unix seconds nor RFC3339 is rejected with 400 rather
than ignored: treating a typo as "from the beginning" would re-deliver your whole alert
history on a routine poll.
**`auto_rule`** tells you whether the platform resolved the alert by itself and why. On
an `AUTO_REFUNDED` alert it names the rule that moved the money (`rule_id`,
`rule_label`, `evaluated_at`). When nothing fired it carries `decision: NONE` and a
`reason` — including the refusals no rule can override, such as `sale-already-refunded`
or `operator-excluded`. It is `null` only on alerts the engine never evaluated.
**`match_evidence.card_bin` / `card_last4` are the card the issuer reported**, and they
may differ from the card you hold for the linked sale without the link being wrong.
Apple Pay and Google Pay charge a device token rather than the card, and virtual card
numbers and reissued cards do the same. Such an alert is linked on the network's own
identifiers for the payment, `arn` and `auth_code`, with the amount, the date and the
card network. Reconcile on `transaction_id`, `arn` or `auth_code`, never on the card
digits.
Requires the `disputes:read` scope. New keys get it by default; an older key may
predate that — add it in Dashboard → Developers if a call answers 403.
Parameters:
- `updated_since` (query, optional, string) — Unix seconds or RFC3339. Exclusive lower bound on `updated_at`.
- `state` (query, optional, string) — NEW | AUTO_REFUNDED | REFUNDED | DISMISSED | ESCALATED.
- `source` (query, optional, string) — ETHOCA | CDRN | RDR.
- `limit` (query, optional, integer) — 1–100, default 25.
- `starting_after` (query, optional, string) — Opaque cursor — echo back `next_cursor`.
Responses:
- 200 — List of pre-dispute alerts
```json
{
"object": "list",
"data": [
{
"id": "al_7Kd9m2QpT4",
"object": "pre_dispute_alert",
"external_id": "ETH-9821",
"source": "ETHOCA",
"state": "AUTO_REFUNDED",
"amount": 12500,
"currency": "usd",
"reason": "fraud",
"priority": "HIGH",
"transaction_id": "tx_3PkC9d2eZvKY",
"nmi_transaction_id": "9182736450",
"customer_id": "4023ec41-ca5f-4153-859f-c30c960a599c",
"processor": "payarc",
"submerchant_kind": "KOUNT_MERCHANT",
"submerchant_id": "554630119933755",
"network_status": "SENT",
"sla_status": "OK",
"resolution_note": "",
"match_evidence": {
"arn": "74126789012345678901234",
"auth_code": "041273",
"card_bin": "414720",
"card_last4": "4242",
"order_id": "ord_8812",
"transaction_date": "2026-08-15T09:12:44Z",
"rung": "arn",
"strength": "strong",
"outcome": "MATCHED"
},
"auto_rule": {
"decision": "AUTO_REFUND",
"rule_id": "rule_9",
"rule_label": "ethoca under $200",
"reason": "",
"evaluated_at": 1786964940
},
"received_at": 1786953600,
"sla_due_at": 1787040000,
"resolved_at": 1786957200,
"updated_at": 1786965000
}
],
"has_more": true,
"next_cursor": "eyJ2YWx1ZSI6IjIwMjYtMDgtMTdUMTE6MzA6MDAuMDAwWiIsImlkIjoiYWxfN0tkOW0yUXBUNCJ9"
}
```
- 400 — parameter_invalid — updated_since was not a timestamp.
### GET /v1/pre_dispute_alerts/{id}
Get one pre-dispute alert by id or external id.
`id` accepts either this API's alert id or the provider's own `external_id`
(ETH-9821 / CDRN-4408 / RDR-2841) — whichever you kept. Both are unique per merchant.
An alert owned by another merchant answers 404, the same as one that does not exist.
Parameters:
- `id` (path, required, string) — The pre-dispute alert id.
Responses:
- 200 — Pre-dispute alert
```json
{
"id": "al_7Kd9m2QpT4",
"object": "pre_dispute_alert",
"external_id": "ETH-9821",
"source": "ETHOCA",
"state": "AUTO_REFUNDED",
"amount": 12500,
"currency": "usd",
"reason": "fraud",
"priority": "HIGH",
"transaction_id": "tx_3PkC9d2eZvKY",
"received_at": 1786953600,
"updated_at": 1786965000
}
```
- 404 — resource_missing.
### GET /v1/disputes/stats
Aggregate dispute counters and recovery amounts.
Roll-up of the merchant's disputes: open/won/lost counts, amount at risk, amount
recovered, and win/recovery rates. The dashboard companion to `GET /v1/disputes`.
Counts every dispute case received in the window — chargebacks, inquiries and retrieval
requests, and later rounds such as pre-arbitration — so `needs_response`, `won` and `lost`
include every case you answer, each at its current status. Pre-dispute alerts and
PayArc RDR resolutions, reversals and credits are not dispute cases and are not counted.
Optional `start_date` / `end_date` (YYYY-MM-DD) window and `processor` / `country`
filters mirror the list. Requires `disputes:read`. Money is in minor units; rates are
whole percents (`63` means 63%). `win_rate` is null while no case in the window is
won or lost.
Parameters:
- `start_date` (query, optional, string) — YYYY-MM-DD inclusive lower bound.
- `end_date` (query, optional, string) — YYYY-MM-DD inclusive upper bound (end of day).
- `processor` (query, optional, string) — Count only disputes from this processor. Omit for every processor.
- `country` (query, optional, string) — Count only disputes from this issuing country. Omit for every country.
Responses:
- 200 — dispute_stats
```json
{
"object": "dispute_stats",
"total": 42,
"needs_response": 5,
"won": 20,
"lost": 12,
"total_amount": 189900,
"recovered_amount": 84000,
"win_rate": 63,
"recovery_rate": 44
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `disputes:read` scope (`permission_denied`).
### GET /v1/disputes
List disputes changed since a timestamp (polling).
The pull side of the `dispute.*` webhooks. Same cursor as `GET /v1/pre_dispute_alerts`:
keep the largest `updated_at` you have seen and pass it back as `updated_since`. Page
with `starting_after` until `has_more` is false.
Requires `disputes:read`, granted by default on new keys (add it in Dashboard →
Developers if an older key answers 403).
Parameters:
- `updated_since` (query, optional, string) — Unix seconds or RFC3339. Exclusive lower bound on `updated_at`.
- `status` (query, optional, string, one of NEEDS_RESPONSE | NEEDS_MANUAL_MATCH | UNDER_REVIEW | WON | LOST | ACCEPTED | EXPIRED | PROCESSING | BUYER_REVIEW | BANK_REVIEW | APPEALABLE | STALE | CLOSED | RELATED_CASE) — Filter by lifecycle state. An unrecognised value is ignored (the filter is dropped and the full list is returned) rather than rejected — so check your spelling if a filtered poll returns more than you expected. `BUYER_REVIEW`, `APPEALABLE` and `BANK_REVIEW` are deprecated and never emitted; they are kept in the enum for wire compatibility only (WRT-152). Filtering on one returns an empty page. A dispute that is with the issuing bank is `UNDER_REVIEW`. `RELATED_CASE` is a PayArc related case (a reversal, resolution record, pre-arbitration, credit or retrieval fulfilment) filed under the dispute that carries its family's state; its `parent_dispute_id` names that dispute.
- `stage` (query, optional, string, one of INQUIRY | CHARGEBACK | PRE_ARBITRATION | ARBITRATION | UNCLASSIFIED) — Filter by escalation stage. An unrecognised value is ignored (the filter is dropped and the full list is returned) rather than rejected.
- `limit` (query, optional, integer, default 25, max 100, min 1) — 1–100, default 25.
- `starting_after` (query, optional, string) — Opaque cursor — echo back `next_cursor`.
Responses:
- 200 — List of disputes
```json
{
"object": "list",
"data": [
{
"id": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
"object": "dispute",
"transaction_id": "9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55",
"nmi_transaction_id": "12437218480",
"billing_name": "Ada Lovelace",
"billing_email": "ada@example.com",
"reason_message": "Cardholder does not recognise the charge",
"card_bin": "424242",
"alert_source": "ETHOCA",
"cardholder_name": "A. LOVELACE",
"transaction_date": 1786966800,
"respond_by": 1787656800,
"closed_at": null,
"status": "NEEDS_RESPONSE",
"parent_dispute_id": "",
"stage": "INQUIRY",
"outcome": "",
"reason": "FRAUDULENT",
"reason_code": "10.4",
"amount": 13550,
"currency": "usd",
"source_acquirer": "Payarc",
"processor_dispute_id": "cb_9f2210",
"kount_alert_id": "f34a64b7-b8ac-4d4c-9245-efb5437d7261",
"card_brand": "visa",
"card_last4": "4242",
"dispute_date": 1787052000,
"due_date": 1787656800,
"created_at": 1787052300,
"updated_at": 1787066400
}
],
"has_more": false,
"next_cursor": null
}
```
- 400 — parameter_invalid — updated_since was not a timestamp.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key is missing the `disputes:read` scope.
### GET /v1/disputes/{id}
Get one dispute by id, processor id, or Kount request id.
`id` accepts this API's dispute id, the processor's dispute id, or
`kount_alert_id` (the Ethoca/CDRN request id).
Parameters:
- `id` (path, required, string) — The dispute id.
Responses:
- 200 — Dispute
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied — the key is missing the `disputes:read` scope.
- 404 — resource_missing. Also returned for a dispute that belongs to another merchant —
the two are deliberately indistinguishable, so an id cannot be confirmed by a
caller who may not read it.
### POST /v1/customers
Create a customer.
`name` is split into first/last for the domain. Proxies to
`CustomerService/CreateCustomer`. Customers are unique per email (per merchant and
env-mode): an email that already has a customer is a `409` `already_exists`, and
`GET /v1/customers?email=` finds it. A retry with the same body `idempotency_key`
and the same email returns the customer the first call created.
Only `email`, `name`, `phone` and `idempotency_key` are read; any other field
(`external_id`, `metadata`, `address`) is ignored, not refused.
Request body (required):
Fields:
- `email` (string, required) — Required. The customer record is keyed on it; sending none is a 400 `invalid_argument`.
- `name` (string, optional)
- `phone` (string, optional)
- `idempotency_key` (string, optional) — Read from the body only: this route ignores an `Idempotency-Key` header. Without one, every call is a new create.
Example:
```json
{
"email": "buyer@example.com",
"name": "Ada Lovelace",
"phone": "+15551230000",
"idempotency_key": "8f14e45f-ceea-467d-9c9b-2b3f0a1c9d21"
}
```
Responses:
- 201 — Customer
```json
{
"id": "cus_1QwXyZ",
"object": "customer",
"email": "buyer@example.com",
"name": "Ada Lovelace",
"phone": "+15551230000",
"metadata": {}
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:write` scope (`permission_denied`).
- 409 — The email already has a customer (`already_exists`). Find it with `GET /v1/customers?email=`.
### GET /v1/customers
List customers (keyset pagination).
Lists customer records for the key's merchant and env-mode, paged with `limit` and
`starting_after`. Requires `customers:read`, which stays closed to publishable keys
because it exposes the merchant's whole book. To let a browser show one returning
buyer their saved cards, mint a customer session instead and use the scoped grant.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `created` (query, optional, object) — Only customers created in this window, in Unix **seconds**, sent as bracketed keys: `created[gte]=1733600000&created[lt]=1733686400`. `created[gte]` and `created[lte]` are inclusive, `created[gt]` and `created[lt]` exclusive, and a bare `created=1733600000` matches that one second. A bound in milliseconds, or not a whole number of seconds, is a 400 `parameter_invalid_integer` naming it; a bound sent twice, or `created=` together with `created[...]`, is a 400 `parameter_invalid`.
Responses:
- 200 — List
```json
{
"object": "list",
"data": [
{
"id": "cus_1QwXyZ",
"object": "customer",
"email": "buyer@example.com",
"name": "Ada Lovelace",
"phone": null,
"metadata": {}
}
],
"has_more": false
}
```
- 400 — parameter_invalid_integer — a `created` bound that is not Unix seconds. parameter_invalid — a `created` bound sent twice, or `created=` together with `created[...]`.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:read` scope (`permission_denied`).
### POST /v1/customers/{id}
Update a customer (POST-to-update, by convention).
Only explicit fields are forwarded (never a raw body spread — blocks a
`merchantAccountId` spoof). Send `name` (auto-split) or `first_name`/`last_name`.
Proxies to `CustomerService/UpdateCustomer`.
**How `name` is split.** The last whitespace-separated token becomes
`last_name`; everything before it becomes `first_name`. So `Mehmet Ali Yılmaz`
is stored as `Mehmet Ali` / `Yılmaz`, and a single-token name is stored as
`first_name` with an empty `last_name`. It is a heuristic, not a parser: a
double surname such as `José García López` keeps only `López`. Send
`first_name` and `last_name` yourself when you know the split — doing so
skips the heuristic entirely.
Parameters:
- `id` (path, required, string) — The customer to update.
Request body (required):
Partial update. Only the fields sent are written; none are required.
Fields:
- `email` (string, optional)
- `name` (string, optional) — Full name. Split on the LAST space — `Mehmet Ali Yılmaz` becomes `Mehmet Ali` / `Yılmaz`. Send `first_name` / `last_name` instead when you know the split.
- `phone` (string, optional)
- `first_name` (string, optional)
- `last_name` (string, optional)
Example:
```json
{
"email": "ada2@example.com",
"name": "Ada Byron",
"phone": "+15551239999"
}
```
Responses:
- 201 — Updated customer
```json
{
"id": "cus_1QwXyZ",
"object": "customer",
"email": "ada2@example.com",
"name": "Ada Byron",
"phone": "+15551239999",
"metadata": {}
}
```
- 404 — resource_missing.
### GET /v1/customers/{id}
Retrieve a customer.
Proxies to `CustomerService/GetCustomer` under `(merchantAccountId, envMode)` from the key.
Parameters:
- `id` (path, required, string) — The customer id.
Responses:
- 200 — Customer
```json
{
"id": "cus_1QwXyZ",
"object": "customer",
"email": "buyer@example.com",
"name": "Ada Lovelace",
"phone": "+15551230000",
"metadata": {}
}
```
- 404 — resource_missing — no such customer under this tenancy.
### GET /v1/customers/{id}/state
Everything needed to decide what this customer may use.
One call in place of a state machine. Without it, granting access means listing
subscriptions, filtering to the live ones, mapping plan ids to your own tiers, and
keeping that derived state correct against a stream of subscription events — the
same code, with the same edge cases, in every integration.
Returns `object: "customer_state"` with the customer's identity (`id` is
`cus_`, as `GET /v1/customers` returns it), the **entitling** subscriptions
only (`active_subscriptions`), and the saved cards as brand + last4. Cacheable
against your own user id; refresh it when a customer or subscription webhook
arrives.
Requires `customers:read`.
Parameters:
- `id` (path, required, string) — The customer id: `cus_` as `GET /v1/customers` returns it, or the bare uuid.
Responses:
- 200 — The customer state.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:read` scope (`permission_denied`).
- 404 — No such customer.
### GET /v1/customers/external/{externalId}/state
The same state, keyed by your own customer id.
Identical response to `GET /v1/customers/{id}/state`, addressed by the customer's
`external_id`. No API call sets `external_id` today (`POST /v1/customers` and
`POST /v1/customers/{id}` do not read it), so a customer you create has none and
this route answers `404` for any id you choose: use `/v1/customers/{id}/state`.
A separate path, not a query flag, on purpose: `?id_type=external` silently falls
back to our-id when the parameter is dropped or misspelled, and a lookup in the
wrong namespace does not error — it returns the wrong customer, or none.
Requires `customers:read`.
Parameters:
- `externalId` (path, required, string) — Your own customer id, as stored in `external_id`.
Responses:
- 200 — The customer state.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:read` scope (`permission_denied`).
- 404 — No customer carries that `external_id` (`resource_missing`, `param: external_id`).
### GET /v1/customers/{id}/transactions
List one customer's charges.
The charges of a single customer, newest first, in the same row shape as
`GET /v1/charges`.
**Which rows.**
- Charges only: sales and authorizations, including those synced from your gateway.
- Never a refund, void or capture row. A refund is not a charge; read refunds with
`GET /v1/refunds?charge=`.
**What each row says.**
- `amount_refunded` is what the charge has already given back. Refunds made through
the API and refunds made in your gateway's own portal are both included.
- `refunded` is `true` once it covers the whole captured amount.
- `disputed` is `true` while a chargeback or a matched pre-dispute alert is open on the
charge. `POST /v1/refunds` refuses such a charge (`TRANSACTION_DISPUTED`).
- `livemode` follows your key.
- `card` is the card the charge was paid with: `brand`, `bin` (left out when none is
held) and `last4`.
- A failed charge carries `failure_code`, `failure_message` and an `outcome` that says
whether the gateway refused it before it reached the card network
(`not_sent_to_network`), our fraud screening blocked it (`not_sent_to_network`,
`type: blocked`, `failure_code: fraudulent`) or the issuer declined it
(`declined_by_network`), as `GET /v1/charges/{id}` does.
Together these are what you need to work out how much of a customer's past spending
you can still refund (see the **Refund a customer's past charges** guide).
**One customer, several cards.** A customer's charges can have been paid with more than
one card, and a refund goes back to the card of the charge it refunds. Refund only the
charges on the card(s) of the person you owe, matched by `brand`, `bin` and `last4`.
**Visibility.** Merchant sale exclusions apply before pagination, using the same
policy as `GET /v1/charges`. Hidden sales are absent. If the policy is unavailable,
the request fails with `503`; it never falls back to unfiltered customer history.
**Paging.** Pass the previous page's `next_cursor` as `starting_after`.
**Unknown customer.** An unknown customer id returns an empty list, not a `404`.
⚠️ Requires **`payments:read`**, not `customers:read`: the rows are payment data,
so the scope follows the data rather than the path.
Parameters:
- `id` (path, required, string) — The customer id.
- `limit` (query, optional, integer, default 10, max 100, min 1) — Page size, 1-100. Defaults to 10.
- `starting_after` (query, optional, string) — The previous page's `next_cursor` (an opaque cursor, not a charge id). A charge id is not decoded and returns the first page again.
Responses:
- 200 — A cursor-paged list of the customer's charges.
```json
{
"object": "list",
"data": [
{
"id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"object": "charge",
"amount": 10000,
"amount_refunded": 2500,
"refunded": false,
"disputed": false,
"currency": "usd",
"status": "succeeded",
"livemode": true,
"card": {
"brand": "visa",
"bin": "424242",
"last4": "4242"
}
}
],
"has_more": true,
"next_cursor": "eyJjIjoiMjAyNi0xMC0wMVQxMjowMDowMFoiLCJpIjoiVFhOLTEifQ"
}
```
- 400 — A blank customer id is rejected; it never becomes an all-customer query.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `payments:read` scope (`permission_denied`).
- 503 — Merchant visibility policy unavailable (`upstream_error`).
### POST /v1/customers/{id}/payment_methods
Attach (vault) a card to a customer.
Accepts an Evervault `ev:` token (`token`, `pan`, or `payment_method.token`) — never a
raw PAN (`raw_pan_rejected`). `set_default` makes it the customer's default. Proxies to
`CustomerService/VaultCard`.
Parameters:
- `id` (path, required, string) — The customer to attach the card to.
Request body (required):
One card, as a token. A raw PAN is refused. Name it with `payment_method`, `token` or `pan` - they are read in that order of precedence, and with none of them the call is a 400.
Fields:
- `payment_method` (optional) — A payment-method object or id naming the card to vault.
- `token` (string, optional) — An `ev:` card token.
- `pan` (string, optional) — Tokenized value only - this edge is outside the card-data environment.
- `set_default` (boolean, optional) — Make this the customer default after vaulting.
Example:
```json
{
"payment_method": {
"token": "ev:card_tok_9c2b…",
"exp_month": 12,
"exp_year": 2030
},
"set_default": true
}
```
Responses:
- 201 — Vaulted payment_method
```json
{
"id": "pm_1Qabcd",
"object": "payment_method",
"customer": "cus_1QwXyZ",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242"
}
}
```
- 400 — parameter_invalid / raw_pan_rejected — a card token is required, raw PANs rejected.
- 403 — `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in test mode), or create an NMI sandbox account first at https://guide.nmi.com/. Nothing was saved.
### GET /v1/customers/{id}/payment_methods
List a customer's vaulted cards.
Proxies to `CustomerService/ListPaymentMethods`.
Parameters:
- `id` (path, required, string) — The customer whose saved cards you want.
Responses:
- 200 — List of payment methods
```json
{
"object": "list",
"data": [
{
"id": "pm_1Qabcd",
"object": "payment_method",
"customer": "cus_1QwXyZ",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242"
}
}
],
"has_more": false
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:read` scope (`permission_denied`).
- 404 — No such customer for this key's merchant and env-mode (`resource_missing`).
### DELETE /v1/customers/{customerId}/payment_methods/{pmId}
Detach (soft-delete) a card from a customer — nested route.
Proxies to `CustomerService/RemovePaymentMethod`. Soft-delete only (the pm id keeps
resolving for downstream consumers).
Parameters:
- `customerId` (path, required, string) — The customer the saved card belongs to.
- `pmId` (path, required, string) — The payment method to detach.
Responses:
- 200 — Deletion acknowledgement
```json
{
"id": "pm_1Qabcd",
"object": "payment_method",
"deleted": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:write` scope (`permission_denied`).
- 404 — No such customer or payment method for this key's merchant and env-mode (`resource_missing`).
### DELETE /v1/customers/payment_methods/{pmId}
Detach a card by pm id (blueprint flat route; customer via ?customer=).
Same effect as the nested route. When used, pass the owning customer via the
`customer` query param so tenancy resolves. Proxies to `CustomerService/RemovePaymentMethod`.
Parameters:
- `pmId` (path, required, string) — The payment method to detach.
- `customer` (query, optional, string) — Required in practice. This route carries no customer in its path, so the id goes here; without it the lookup runs against an empty customer and always answers 404. Prefer `DELETE /v1/customers/{customerId}/payment_methods/{pmId}`, which cannot be called wrong.
Responses:
- 200 — Deletion acknowledgement
```json
{
"id": "pm_1Qabcd",
"object": "payment_method",
"deleted": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `customers:write` scope (`permission_denied`).
- 404 — No such customer or payment method (`resource_missing`). Also what you get when the `customer` query parameter is omitted: the lookup then runs against an empty customer id and can never match.
### POST /v1/checkout-links
Create a hosted checkout link.
Proxies to `CommerceCheckoutService/CreatePaymentLink`. `name` is required (min length 1).
`link_type` FIXED requires `amount`. `link_type` SUBSCRIPTION also requires `plan` (an
11-billing plan id); omitting it is `400 parameter_missing`. There is no idempotency
key on the domain RPC.
Request body (required):
`name` is required, but `description` is accepted in its place - the edge falls back to it. With neither, the call is a 400 `parameter_invalid`.
Fields:
- `amount` (integer, optional) — Minor units. Ignored for a DONATION link.
- `currency` (string, optional) — ISO 4217.
- `name` (string, optional)
- `description` (string, optional)
- `link_type` (string, optional, one of FIXED | DONATION | SUBSCRIPTION)
- `plan` (string, optional) — Required when `link_type` is SUBSCRIPTION. A `plan_…` id from POST/GET /v1/plans.
- `idempotency_key` (string, optional)
Example:
```json
{
"name": "Order 1234",
"amount": 1999,
"currency": "usd",
"link_type": "FIXED",
"description": "One pipe wrench"
}
```
Responses:
- 201 — checkout_link
```json
{
"id": "df62a7a3-e2de-4f47-8635-e3c9adfebddf",
"object": "checkout_link",
"url": "https://app.worthypayments.com/checkout/lKHx64iW",
"code": "lKHx64iW",
"active": true
}
```
- 400 — parameter_invalid — name is required. parameter_missing — `plan` is required when `link_type` is SUBSCRIPTION.
### GET /v1/checkout-links
List checkout links.
Proxies to `CommerceCheckoutService/ListPaymentLinks`. Cursor-paged with `starting_after`
(same shape as GET /v1/charges). Scoped to the key's merchant. Requires
`checkout_links:read`. `active` and `link_type` are optional filters; an absent `active`
lists both active and inactive links. Money fields are in minor units, absent for a
DONATION link with no fixed amount.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `active` (query, optional, boolean) — Send `true` for live links only or `false` for inactive ones. Omit for both.
- `link_type` (query, optional, string, one of FIXED | DONATION | SUBSCRIPTION) — Return only links of this type.
Responses:
- 200 — List
```json
{
"object": "list",
"data": [
{
"id": "df62a7a3-e2de-4f47-8635-e3c9adfebddf",
"object": "checkout_link",
"url": "https://app.worthypayments.com/checkout/lKHx64iW",
"code": "lKHx64iW",
"name": "Order 1234",
"link_type": "FIXED",
"currency": "usd",
"amount": 1999,
"times_used": 3,
"active": true
}
],
"has_more": false
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `checkout_links:read` scope (`permission_denied`).
### GET /v1/checkout-links/{id}
Fetch a checkout link.
Proxies to `CommerceCheckoutService/GetPaymentLink`. Scoped to the key's merchant.
Requires `checkout_links:read`.
Parameters:
- `id` (path, required, string) — The checkout link id.
Responses:
- 200 — checkout_link
```json
{
"id": "df62a7a3-e2de-4f47-8635-e3c9adfebddf",
"object": "checkout_link",
"url": "https://app.worthypayments.com/checkout/lKHx64iW",
"code": "lKHx64iW",
"name": "Order 1234",
"link_type": "FIXED",
"currency": "usd",
"amount": 1999,
"times_used": 3,
"active": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `checkout_links:read` scope (`permission_denied`).
- 404 — No such checkout link for this key's merchant and env-mode (`resource_missing`).
### GET /v1/products
List products.
Lists the merchant's catalogue products for the key's env-mode, paged with `limit`
and `starting_after`. Requires `products:read`.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
Responses:
- 200 — List
```json
{
"object": "list",
"data": [
{
"id": "prod_9aZ",
"object": "product",
"name": "Pipe wrench",
"active": true,
"url": "https://pay.paygeyt.com/p/9aZ"
}
],
"has_more": false
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `products:read` scope (`permission_denied`).
### POST /v1/products/{id}/tax_rates
Attach a tax rate to a product.
Pins one of your tax rates onto a product, so `POST /v1/tax/calculate` taxes that
product's lines with it. Proxies `BillingService/SetProductTaxRate`. Requires
`tax:write` (server-only; publishable keys are refused).
A product can carry several rates; they stack (for example a state rate plus a city
rate). Attaching the same rate twice is harmless and returns the same mapping.
The product id is stored as a reference and is **not** checked against the catalog,
so use the id you will pass as `line_items[].product` to `POST /v1/tax/calculate` —
normally the catalog id from `GET /v1/products`, which is also what the hosted
checkout sends. An inactive or deleted rate stays attached but is never applied.
Parameters:
- `id` (path, required, string) — The product id, as you pass it to `POST /v1/tax/calculate`.
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key replays the first response; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h.
Request body (required):
Fields:
- `tax_rate` (string, required) — The id of one of your tax rates.
Example:
```json
{
"tax_rate": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11"
}
```
Responses:
- 200 — The mapping.
```json
{
"id": "0d3e9f62-58a4-4c71-8f2b-7e1a6c5d4b30",
"object": "product_tax_rate",
"product": "4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1",
"tax_rate": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"created": 1790856000
}
```
- 400 — `parameter_invalid` (no `tax_rate`) or `parameter_unknown`.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:write` scope (`permission_denied`).
- 404 — `resource_missing` — no live tax rate with that id for this key's account and mode.
### DELETE /v1/products/{id}/tax_rates/{tax_rate}
Detach a tax rate from a product.
Removes the product-to-rate mapping. The tax rate itself is untouched and stays
attached to any other product. Proxies `BillingService/DeleteProductTaxRate`.
Requires `tax:write`.
Once no rate is attached to a product, its lines fall back to your location rates
(country / state / city), then to your default rate, in `POST /v1/tax/calculate`.
Parameters:
- `id` (path, required, string) — The product id the rate was attached to.
- `tax_rate` (path, required, string) — The id of the attached tax rate.
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key replays the first response instead of answering 404 the second time. Keys are stored for 24h.
Responses:
- 200 — The removed mapping.
```json
{
"id": "0d3e9f62-58a4-4c71-8f2b-7e1a6c5d4b30",
"object": "product_tax_rate",
"product": "4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1",
"tax_rate": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"created": 1790856000,
"deleted": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:write` scope (`permission_denied`).
- 404 — `resource_missing` — that rate is not attached to that product.
### POST /v1/tax_rates
Create a tax rate.
Creates a tax rate on your account, in the key's mode. Proxies
`BillingService/UpsertTaxRate`, the same call the dashboard's tax-rate page makes.
Requires `tax:write` (server-only; publishable keys are refused).
`percentage` is a **percent**, not a fraction: `8.25` means 8.25 %. Send it as a JSON
number or a decimal string, between 0 and 100 with at most two decimals; it is always
returned as a decimal string (`"8.25"`), never a float.
Where it applies: attach it to products with `POST /v1/products/{id}/tax_rates`, or
give it a `country` (and optionally `state` / `city`) so it applies to every line
shipped there that has no product rate of its own. A new rate is `active` unless you
send `active: false`. Default and global rates are set in the dashboard.
Parameters:
- `Idempotency-Key` (header, required, string) — Required. A retried create would otherwise make a second rate. A repeat of the same key replays the first response with `X-Idempotency-Replayed: true`; the same key with a different body is `idempotency_error`. Keys are stored for 24h.
Request body (required):
Fields:
- `display_name` (string, required) — Shown on receipts and in the tax breakdown.
- `description` (string, optional)
- `percentage` (number,string, required) — Percent, 0–100, at most two decimals. `8.25` = 8.25 %.
- `inclusive` (boolean, optional) — `true` when your prices already include this tax.
- `tax_type` (string, optional, one of VAT | GST | SALES_TAX | HST | PST | CUSTOM | OTHER) — Case-insensitive.
- `jurisdiction` (string, optional)
- `country` (string, optional) — ISO 3166-1 alpha-2, e.g. `US`.
- `state` (string, optional) — State or province code, e.g. `CA`.
- `city` (string, optional)
- `active` (boolean, optional)
Example:
```json
{
"display_name": "CA sales tax",
"percentage": 7.25,
"tax_type": "SALES_TAX",
"country": "US",
"state": "CA"
}
```
Responses:
- 200 — The created tax rate.
```json
{
"id": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"object": "tax_rate",
"display_name": "CA sales tax",
"description": null,
"percentage": "7.25",
"inclusive": false,
"tax_type": "SALES_TAX",
"jurisdiction": null,
"country": "US",
"state": "CA",
"city": null,
"active": true,
"is_default": false,
"created": 1790856000,
"updated": 1790856000
}
```
- 400 — `idempotency_key_required`, `parameter_invalid` (missing `display_name` / `percentage`, a percentage out of range or with a third decimal, an unknown `tax_type`) or `parameter_unknown`.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:write` scope (`permission_denied`).
- 409 — `idempotency_in_progress` — the same key is still being processed.
### GET /v1/tax_rates
List tax rates.
Your account's tax rates in the key's mode, newest first, including inactive ones
unless `active=true`. Deleted rates are never listed. Proxies
`BillingService/ListTaxRates`. Requires `tax:read`.
Cursor-paged: pass the previous page's `next_cursor` as `starting_after` and keep
paging while `has_more` is true.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — The opaque `next_cursor` from the previous page. Not a tax rate id.
- `active` (query, optional, boolean) — `true` returns only active rates.
Responses:
- 200 — A page of tax rates.
```json
{
"object": "list",
"data": [
{
"id": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"object": "tax_rate",
"display_name": "CA sales tax",
"description": null,
"percentage": "7.25",
"inclusive": false,
"tax_type": "SALES_TAX",
"jurisdiction": null,
"country": "US",
"state": "CA",
"city": null,
"active": true,
"is_default": false,
"created": 1790856000,
"updated": 1790856000
}
],
"has_more": false
}
```
- 400 — `invalid_request` — the `starting_after` cursor is not one this list issued.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:read` scope (`permission_denied`).
### POST /v1/tax_rates/{id}
Update a tax rate.
Changes only the fields you send; every other field keeps its stored value,
including the ones this API does not expose (default, global, stacking). Accepts
the same fields as create, none of them required. Send `null` or `""` to clear a
text field. Requires `tax:write`.
A changed `percentage` applies to every calculation from now on; tax already
recorded on completed orders is not recomputed.
Parameters:
- `id` (path, required, string) — The tax rate id.
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key replays the first response; the same key with a different body is `idempotency_error`. Keys are stored for 24h.
Request body (required):
Fields:
- `display_name` (string,null, optional)
- `description` (string,null, optional)
- `percentage` (number,string, optional) — Percent, 0–100, at most two decimals.
- `inclusive` (boolean, optional)
- `tax_type` (string,null, optional, one of VAT | GST | SALES_TAX | HST | PST | CUSTOM | OTHER | )
- `jurisdiction` (string,null, optional)
- `country` (string,null, optional)
- `state` (string,null, optional)
- `city` (string,null, optional)
- `active` (boolean, optional)
Example:
```json
{
"percentage": "7.5"
}
```
Responses:
- 200 — The updated tax rate.
```json
{
"id": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"object": "tax_rate",
"display_name": "CA sales tax",
"description": null,
"percentage": "7.5",
"inclusive": false,
"tax_type": "SALES_TAX",
"jurisdiction": null,
"country": "US",
"state": "CA",
"city": null,
"active": true,
"is_default": false,
"created": 1790856000,
"updated": 1790942400
}
```
- 400 — `parameter_invalid` or `parameter_unknown`.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:write` scope (`permission_denied`).
- 404 — `resource_missing` — no such tax rate for this key.
### GET /v1/tax_rates/{id}
Retrieve a tax rate.
One tax rate by id, scoped to the key's account and mode. A rate of another account
or the other mode, or a deleted one, is `404`. Requires `tax:read`.
The response is the same `tax_rate` object the list and create return. `is_default`
is read-only here: the default rate is chosen in the dashboard.
Parameters:
- `id` (path, required, string) — The tax rate id.
Responses:
- 200 — The tax rate.
```json
{
"id": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"object": "tax_rate",
"display_name": "CA sales tax",
"description": null,
"percentage": "7.25",
"inclusive": false,
"tax_type": "SALES_TAX",
"jurisdiction": null,
"country": "US",
"state": "CA",
"city": null,
"active": true,
"is_default": false,
"created": 1790856000,
"updated": 1790856000
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:read` scope (`permission_denied`).
- 404 — `resource_missing` — no such tax rate for this key.
### DELETE /v1/tax_rates/{id}
Delete a tax rate.
Deletes the rate: it stops applying to every calculation at once and disappears
from the list. The record is kept (soft delete) so tax already recorded against it
stays explained. Product attachments to it stop applying with it. Proxies
`BillingService/DeleteTaxRate`. Requires `tax:write`.
To pause a rate you may want back, send `active: false` on update instead.
Parameters:
- `id` (path, required, string) — The tax rate id.
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key replays the first response instead of answering 404 the second time. Keys are stored for 24h.
Responses:
- 200 — The deleted tax rate.
```json
{
"id": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"object": "tax_rate",
"deleted": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:write` scope (`permission_denied`).
- 404 — `resource_missing` — no such tax rate for this key.
### POST /v1/tax/calculate
Calculate tax for a cart, line by line.
Returns the tax each line owes and the cart totals, from your own tax rates.
Writes nothing, so it needs `tax:read` and no `Idempotency-Key`; call it as often as
the cart changes. Amounts are integer minor units (cents).
Each line resolves its rates on its own: a rate set to apply globally first, then the
rates attached to the line's `product`, then your rates for the ship-to `address`
(country / state / city), then your default rate. So a cart can hold a taxed line
and an untaxed one. Tax on an `inclusive` rate is already inside the line `amount`
and is not added again: `amount_total` = `amount_subtotal` + `tax_amount_exclusive`.
At most 100 line items. Shipping is not taxed by this call.
Request body (required):
Fields:
- `currency` (string, optional) — ISO 4217.
- `line_items` (array, required)
- `address` (object, optional) — The ship-to address the location rates resolve on.
- `address.country` (string, optional) — ISO 3166-1 alpha-2.
- `address.state` (string, optional)
- `address.city` (string, optional)
- `address.postal_code` (string, optional)
Example:
```json
{
"currency": "usd",
"address": {
"country": "US",
"state": "CA",
"city": "Los Angeles",
"postal_code": "90001"
},
"line_items": [
{
"product": "4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1",
"amount": 2000,
"quantity": 2,
"reference": "cart-item-1"
},
{
"product": "9c2d1b30-7e44-4a5f-b8d6-1f0e3a2c5b97",
"amount": 500
}
]
}
```
Responses:
- 200 — The calculation.
```json
{
"object": "tax_calculation",
"currency": "usd",
"amount_subtotal": 2500,
"tax_amount_exclusive": 181,
"tax_amount_inclusive": 0,
"amount_total": 2681,
"line_items": [
{
"reference": "cart-item-1",
"product": "4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1",
"quantity": 2,
"amount": 2000,
"amount_tax": 145,
"tax_breakdown": [
{
"tax_rate": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"display_name": "CA sales tax",
"tax_type": "SALES_TAX",
"percentage": "7.25",
"jurisdiction": null,
"country": "US",
"state": "CA",
"city": null,
"inclusive": false,
"taxable_amount": 2000,
"amount": 145
}
]
},
{
"reference": null,
"product": "9c2d1b30-7e44-4a5f-b8d6-1f0e3a2c5b97",
"quantity": 1,
"amount": 500,
"amount_tax": 36,
"tax_breakdown": [
{
"tax_rate": "6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11",
"display_name": "CA sales tax",
"tax_type": "SALES_TAX",
"percentage": "7.25",
"jurisdiction": null,
"country": "US",
"state": "CA",
"city": null,
"inclusive": false,
"taxable_amount": 500,
"amount": 36
}
]
}
]
}
```
- 400 — `parameter_invalid` (no or too many `line_items`, a non-integer `amount`, a zero `quantity`, an unknown `currency` or `address.country`) or `parameter_unknown`.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `tax:read` scope (`permission_denied`).
### GET /v1/wallet-config
Public wallet config for the checkout SDK (Apple/Google Pay).
Whether Apple Pay and Google Pay are on for this merchant. Wallets run on the
Evervault rail.
- `EVERVAULT` — the wallet sheet returns an encrypted network token plus a
cryptogram, which you post to `/v1/charges` as the `wallet` block.
`evervaultMerchantId` is what the button mounts against, so a wallet is only
reported enabled when that id is present.
- `NONE` — no wallet is available for this account.
Fails soft: if an upstream errors, the response degrades to "no wallet" rather
than failing the call. Treat the flags as **fail-closed** — mount a button only
on a literal `true`.
These flags describe the *account*. Whether the buyer's device can actually pay
is decided at mount time: Apple Pay is gated on the SDK's availability check, and
Google Pay's web flow runs only in Chromium browsers. A mount that renders
nothing is a normal outcome, not an error.
No secrets are returned — only publishable ids.
Responses:
- 200 — wallet_config
```json
{
"object": "wallet_config",
"mode": "test",
"provider": "EVERVAULT",
"applePay": true,
"googlePay": true,
"evervaultMerchantId": "merchant_2f9a1c4d8e"
}
```
- 401 — Missing or invalid API key (`authentication_required`).
### GET /v1/wp-config
The public half of the WorthyPayments card keypair.
What the browser SDK needs to seal a card into a `wp:` envelope. Scope `none`,
so a publishable key reads it.
**Publishing the public key is the design, not a leak.** Anyone can mint a
well-formed `wp:` token; what stops abuse is everything behind it — velocity,
throttling, the audience binding, and the single-use `jti`. Secrecy of this
value was never part of the defence.
Like `/v1/evervault-config`, this is **not per-merchant**: one platform
keypair serves everyone, and the envelope's `aud` claim is what binds a token
to a merchant, enforced in 03-gateway-adapter.
`alg` is a suite string a client must recognise or refuse — never ignore it and
encrypt anyway. Cached for an hour, which is safe across a rotation because 03
also accepts the previous key for the overlap window.
Returns `503` when no key is configured, and that response is deliberately not
cached: a cached 503 would outlive the deploy that fixes it.
Responses:
- 200 — The active card-envelope key.
```json
{
"kid": 1,
"public_key": "BEys...65-byte-P256-point-base64",
"alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
"info": "worthy-card-v1",
"token_ttl_seconds": 900
}
```
- 401 — api_key_required / api_key_invalid.
- 503 — wp_not_configured — no card key on this environment.
### GET /v1/element-frameable
May this origin embed the Payment Element?
The element's own server calls this before serving the card frame, and the
answer becomes that response's `frame-ancestors`. Scope `none`.
**A boolean, never the domain list.** Returning `allowed_domains` would let
anyone holding a publishable key — a public string — enumerate every host a
merchant runs: staging, internal tools, whatever was allowlisted once and
forgotten. The caller already knows the origin it is asking about, so a yes/no
tells it nothing it did not bring.
Matches the same allowlist that governs which origins may charge with the key
(exact host, or a single-level `*.` wildcard).
**Fails closed on an unconfigured key.** No allowlist means no element —
"any site may render a card form on our origin" is not a safe default for a
merchant who simply never filled the field in. This differs on purpose from
the charge-time domain check, where an empty list means "no origin lock" and
is correct for a server-to-server call.
Query `origin` is optional. When it is omitted, the `Origin` header is the
origin under test. Prefer the header for loopback parents
(`http://localhost`, `http://127.0.0.1`): a `?origin=http://localhost…`
query is HTML 403 at the public edge, and never reaches this handler.
The element's in-cluster proxy already sends `Origin` and omits the
query for loopback.
Parameters:
- `origin` (query, optional, string) — The embedding origin, e.g. `https://shop.example.com`. https only, except loopback http. Optional when the `Origin` header is set.
Responses:
- 200 — Whether that origin may frame this key's element.
```json
{
"object": "element_frameable",
"allowed": true
}
```
- 401 — api_key_required / api_key_invalid.
### GET /v1/evervault-config
Evervault team/app ids for the front-end relay.
Publishable-key-safe (`RequireScopes("none")`) — this is the endpoint the browser SDK
reads, NOT `/v1/3ds/config`, which requires `threeds:read`: a scope publishable keys
cannot hold, because opening a 3DS session bills the merchant's acquirer.
Ids are resolved per mode: a test key gets the platform's test Evervault app, a live
key its live app.
An `ev:` token is encrypted against one Evervault app and only that app can decrypt it,
so hardcoding ids — or reusing a test token in live — produces a charge that fails at
the gateway with an opaque error.
Falls back to the edge's own per-mode ids when payments is unreachable, so a transient
upstream fault still yields the pair for your key's mode rather than an SDK that cannot
encrypt a card. Returns null ids when nothing is configured.
Wallet availability lives on `GET /v1/wallet-config`, also scope `none`.
Responses:
- 200 — evervault_config
```json
{
"object": "evervault_config",
"mode": "test",
"team_id": "team_abc123",
"app_id": "app_xyz789"
}
```
- 401 — Missing or invalid API key (`authentication_required`).
### GET /v1/kount-config
Kount device data collection coordinates for the browser SDK.
Publishable-key-safe (`RequireScopes("none")`). Returns the issued client id and which
Kount environment to collect against — no credentials. The browser SDK calls this and
attaches the resulting session id to `device.kount_session_id` on the charge.
Mode comes from the KEY, never a header: a TEST key always collects against the sandbox
tenant.
`enabled: false` means "do not attempt collection" — the merchant is not provisioned, or
collection is switched off. Skip DDC and charge anyway; Kount is a fraud signal, not a
gate, and a config outage must never block a payment.
Merchants must allow `frame-src https://*.kaptcha.com` in their CSP. The collector runs
in a hidden iframe, so without that entry it silently collects nothing.
Responses:
- 200 — kount_config
- 401 — Missing or invalid API key (`authentication_required`).
### POST /v1/webhook-endpoints
Create a webhook endpoint (signing secret returned once).
Proxies to `WebhookService/CreateWebhook`. The `secret` is returned **only** on create
(and on roll-secret) — never on list/get. An `Idempotency-Key` header is honored (else
derived from the url).
Parameters:
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key returns the original endpoint rather than creating a second one. Omitted, the key is derived from the endpoint URL.
Request body (required):
Fields:
- `url` (string, required) — HTTPS destination. Missing or blank is a 400 `parameter_missing`. The owning service then validates the URL (https, SSRF).
- `enabled_events` (array, optional) — Event types to deliver. Omit for the account default.
Example:
```json
{
"url": "https://merchant.example.com/hooks/worthy",
"enabled_events": [
"charge.succeeded",
"charge.refunded"
]
}
```
Responses:
- 201 — webhook_endpoint (with one-time secret)
```json
{
"id": "we_5RtG",
"object": "webhook_endpoint",
"url": "https://merchant.example.com/hooks/worthy",
"status": "enabled",
"enabled_events": [
"charge.succeeded",
"charge.refunded"
],
"secret": "whsec_9f3aC…"
}
```
- 400 — parameter_missing — `url` was omitted or blank.
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `webhooks:write` scope (`permission_denied`).
### GET /v1/webhook-endpoints
List webhook endpoints (no secrets).
Returns every webhook endpoint configured for the key's merchant and env-mode, and
requires `webhooks:read`.
Unlike the other collections on this API it takes no `limit` or `starting_after`:
the handler accepts no paging parameters and the whole list comes back in one
response.
Responses:
- 200 — List
```json
{
"object": "list",
"data": [
{
"id": "we_5RtG",
"object": "webhook_endpoint",
"url": "https://merchant.example.com/hooks/worthy",
"status": "enabled",
"enabled_events": [
"charge.succeeded"
]
}
],
"has_more": false
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `webhooks:read` scope (`permission_denied`).
### GET /v1/webhook-endpoints/{id}
Retrieve a webhook endpoint (no secret).
Proxies to `WebhookService/GetWebhook`.
Parameters:
- `id` (path, required, string) — The webhook endpoint id.
Responses:
- 200 — webhook_endpoint
```json
{
"id": "we_5RtG",
"object": "webhook_endpoint",
"url": "https://merchant.example.com/hooks/worthy",
"status": "enabled",
"enabled_events": [
"charge.succeeded"
]
}
```
- 404 — resource_missing — no such webhook endpoint.
### DELETE /v1/webhook-endpoints/{id}
Delete a webhook endpoint.
Proxies to `WebhookService/DeleteWebhook`. Reports the domain's real `deleted` flag.
Parameters:
- `id` (path, required, string) — The webhook endpoint to delete.
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key returns the original endpoint rather than creating a second one. Omitted, the key is derived from the endpoint URL.
Responses:
- 200 — Deletion acknowledgement
```json
{
"id": "we_5RtG",
"object": "webhook_endpoint",
"deleted": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `webhooks:write` scope (`permission_denied`).
- 404 — No such webhook endpoint for this key's merchant and env-mode (`resource_missing`).
### POST /v1/webhook-endpoints/{id}/roll-secret
Rotate a webhook endpoint's signing secret (new secret returned once).
Proxies to `WebhookService/RegenerateSecret`. The new `secret` is shown exactly once.
Parameters:
- `id` (path, required, string) — The webhook endpoint whose signing secret to roll.
- `Idempotency-Key` (header, optional, string) — Optional. A repeat of the same key returns the original endpoint rather than creating a second one. Omitted, the key is derived from the endpoint URL.
Responses:
- 201 — webhook_endpoint with the new secret
```json
{
"id": "we_5RtG",
"object": "webhook_endpoint",
"url": "https://merchant.example.com/hooks/worthy",
"status": "enabled",
"enabled_events": [
"charge.succeeded"
],
"secret": "whsec_NEW7k2…"
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `webhooks:write` scope (`permission_denied`).
- 404 — No such webhook endpoint for this key's merchant and env-mode (`resource_missing`).
### GET /v1/events
List delivered events (webhook deliveries; page-based).
Proxies to `WebhookService/ListDeliveries`. Pagination is **page-based** (not keyset):
`starting_after` is treated as a 1-based page number and `next_cursor` is the next page
number when a full page is returned.
**A list item WRAPS the delivered envelope; it is not the same shape as the pushed
body.** The item has five keys (`id`, `object`, `type`, `created`, `event_id`) and
`data` holds the whole envelope that was POSTed, so the resource sits at
`data.data.object` — one level deeper than in a webhook body — and `api_version` /
`livemode` are at `data.api_version` / `data.livemode`.
**`id` and `event_id` are different ids.** `id` names this *delivery* (fan-out writes
one row per subscribed endpoint) and is the only value `GET /v1/events/{id}` resolves.
`event_id` names the *occurrence*: it is the `X-Webhook-Id` header and the `id` inside
the delivered envelope, shared by every delivery of that occurrence. Dedupe retries on
`id` and fan-out on `event_id`; `GET /v1/events/{X-Webhook-Id}` answers `404`.
`created` is **unix seconds**, like every other resource here.
Parameters:
- `limit` (query, optional, integer, default 20, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `type` (query, optional, string) — Filter to one event type (`charge.succeeded`). Applied in SQL, so `limit` still means up to N MATCHING rows.
- `created_after` (query, optional, string) — Unix seconds or RFC3339. Anything else is rejected with `parameter_invalid` rather than silently ignored.
Responses:
- 200 — List of events
```json
{
"object": "list",
"data": [
{
"id": "evt_3f2b4a86-b0e7-4d41-9c6f-2a915c1d90a7",
"object": "event",
"type": "charge.succeeded",
"created": 1733600000,
"event_id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"data": {
"id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"object": "event",
"api_version": "2026-01-01",
"created": 1733600000,
"livemode": false,
"type": "charge.succeeded",
"data": {
"object": {
"transactionId": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"amount": {
"minorUnits": "2000",
"currencyCode": "USD"
}
}
}
}
}
],
"has_more": true,
"next_cursor": "2"
}
```
- 400 — parameter_invalid — `created_after` is neither unix seconds nor RFC3339.
### GET /v1/events/{id}
Retrieve a single delivered event.
The `evt_` prefix is stripped to the underlying **delivery** id. Proxies to
`WebhookService/GetDelivery`.
⚠️ **Keyed on the delivery id — the `id` from `GET /v1/events`, not the `event_id`
and not the `X-Webhook-Id` header.** Both are `evt_`-prefixed, which is what makes
them easy to confuse; passing the occurrence id here answers `404`. To find the
polled row for a pushed body, list with `type` + `created_after` and match on
`event_id`.
The response has the same shape as one list item: `data` is the whole delivered
envelope, so the resource is at `data.data.object`, and `created` is unix seconds.
Parameters:
- `id` (path, required, string) — The delivery id (`evt_…`) from `GET /v1/events`.
Responses:
- 200 — event
```json
{
"id": "evt_3f2b4a86-b0e7-4d41-9c6f-2a915c1d90a7",
"object": "event",
"type": "charge.succeeded",
"created": 1733600000,
"event_id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"data": {
"id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
"object": "event",
"api_version": "2026-01-01",
"created": 1733600000,
"livemode": false,
"type": "charge.succeeded",
"data": {
"object": {
"transactionId": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"amount": {
"minorUnits": "2000",
"currencyCode": "USD"
}
}
}
}
}
```
- 404 — resource_missing — no such delivery for this merchant (tenancy-scoped), or an occurrence id was passed instead of a delivery id.
### POST /v1/test/subscriptions/{id}/bill
Run this subscription's renewal now (TEST keys only).
Stripe Test Clock analogue. Proxies to `BillingService/BillSubscriptionNow`,
the same function the calendar renew worker uses. A LIVE key is refused with
`test_mode_only` before 11 is touched. Idempotency-Key required. Scope
`subscriptions:write`.
CHARGE_AUTOMATICALLY (default): charges the saved card for the next period
and writes a PAID invoice listed on `GET /v1/invoices`. SEND_INVOICE:
finalizes and emails an OPEN invoice, no card charge. A decline is
`subscription.past_due`, not `invoice.payment_failed`.
Parameters:
- `id` (path, required, string) — Subscription id (`sub_…` or bare uuid).
- `Idempotency-Key` (header, required, string) — UUIDv4. A replay returns the stored result instead of billing twice.
Responses:
- 200 — Subscription after the renewal attempt
- 400 — `test_mode_only` on a LIVE key; `idempotency_key_required` without the header.
- 403 — The key lacks `subscriptions:write`.
### POST /v1/test/tokens
Mint deterministic fake Evervault tokens (TEST mode only).
Publishable-key-safe. Rejects a LIVE key with `test_mode_only`. Returns fake `ev:` tokens
for building test charges without the Evervault SDK. Never accepts or echoes a real PAN.
⚠️ A TEST charge reaches NMI's sandbox, which accepts **seven** card numbers and
refuses everything else with `Bad Request. Please use a valid test card or test ACH
account from the documentation.` The brand aliases below mint exactly those. Verified
2026-09-06; the full table, including the AVS/CVV and decline triggers, is in
[Testing](../developers/15-testing.md).
Request body:
TEST mode only. A live key is a 400 `test_mode_only`. Fields optional; omitted `number`/`card` mints the sandbox Visa 4111111111111111 (label `1111`). `card` is a brand alias (`visa`, `mastercard`, `mc`, `amex`, `discover`, `jcb`, `diners`, `maestro`).
Fields:
- `number` (string, optional) — Full test PAN, or a brand alias (`visa` / `mastercard` / `mc` / `amex` / `discover` / `jcb` / `diners` / `maestro`). Last 4 digits become the token label.
- `card` (string, optional) — Brand alias: visa, mastercard, mc, amex, discover, jcb, diners, maestro. Unknown value is 400 parameter_invalid. ⚠️ visa, mastercard, mc and amex all mint label 1111 — the sandbox Visa, MasterCard and Amex end in the same four digits, and the charge runs as Visa. Type the real number into the Payment Element to exercise the other two brands.
- `cvc` (string, optional) — Request field only. The response names it `cvv`, the way /charges reads it.
Example:
```json
{
"number": "4111111111111111",
"cvc": "123"
}
```
Responses:
- 201 — test_token. The `card` object is shaped to go STRAIGHT into `POST /v1/charges` or the Element confirm — `cvv` is named the way both of them read it, so there is nothing to rename in between.
**No expiry here, and that is not an omission.** A test token stands in for the card SECRET, not the card, so the expiry is yours to supply. Any future date works; the platform's own default is month `12` of the current year + 2.
```json
{
"object": "test_token",
"card": {
"number": "ev:test_token_pan_1111",
"cvv": "ev:test_token_cvc_xxx",
"last4": "1111"
}
}
```
- 400 — test_mode_only — a LIVE key was used.
### POST /v1/check
Pre-checkout fraud evaluation (transaction-independent, advisory).
Evaluate a buyer BEFORE a card is tokenized — no transaction_id / card token required.
The buyer `email` is HMAC-hashed at the edge (raw PII never reaches Fraud-Scoring) and
`ip` falls back to the trusted client IP. Proxies to `ScoringService/CheckStandalone`.
After the inline Cascade check and before velocity, an active device/IP bypass
link to a currently blocked customer returns `blocked`/`deny`, risk score 99,
and the fixed reason `BLOCKED_BYPASS_LINK`. Reasons never include a customer
or anchor identifier. Cascade sync-block gates both link axes; device-enabled
additionally gates device links. The inline-IP switch does not gate IP links.
Missing/NULL device or sync-block settings default on; stored false disables
the matching protection. Earlier allowlist and fraud-disabled decisions win.
ADVISORY ONLY: the real `POST /v1/charges` runs its own fraud pass server-side, so a
developer who skips or ignores /check can never bypass fraud enforcement. FAIL-OPEN —
an unavailable Fraud-Scoring RPC degrades to `clean`/`allow`. Within the engine,
a cascade-config read error keeps the armed defaults, so a link can still deny;
a link-probe error skips only that probe.
When `EMAIL_HASH_KEY` is unset on TEST, the edge still calls CheckStandalone with an
empty `emailHash` so IP / device / BIN / velocity still score. The verdict is 04's;
`reasons` also contains `fraud_hash_unavailable` so this is not Radar-on-by-default.
LIVE without the hash key is 400 `fraud_hash_unavailable`.
Requires scope `check:read` and an API key whose exec-mode is `ATOMIC` or `FRAUD_ONLY`
(a `PAYMENT_ONLY` key is rejected with `mode_not_allowed`). The mode is read from the
KEY, never the request body.
**`risk_decision_token`** — a signed receipt for this evaluation, present when token
signing is configured. Echo it on `POST /v1/charges` to record which decision a charge
followed. It is EVIDENCE, not authorization: the charge runs the full fraud engine
either way, and a token can only ever cause a charge to be REJECTED (see below), never
to skip screening.
The token binds this merchant, the verdict, and whichever of `amount`+`currency`,
`card_bin`+`last4` and `fingerprint` you supplied here. Fields you omit are not bound,
and the matching check is skipped at charge time rather than silently assumed. Send
`last4` alongside `card_bin` if you want the card binding enforced. Valid for 15
minutes, single use.
Request body (required):
Advisory and transaction-independent. Calling it never moves money. Missing `email` is a 400 `email_required`.
Fields:
- `email` (string, required)
- `ip` (string, optional) — The buyer IP. Omit it and your egress IP is scored instead.
- `fingerprint` (string, optional)
- `card_bin` (string, optional)
- `bin` (string, optional)
- `last4` (string, optional) — Not scored. With the bin it binds the verdict to one card, so a token cannot be replayed onto another.
- `billing_country` (string, optional)
- `amount` (integer, optional) — Minor units. A verdict is bound to the amount it was issued for.
- `currency` (string, optional)
- `transaction_type` (string, optional)
Example:
```json
{
"email": "buyer@example.com",
"ip": "203.0.113.9",
"fingerprint": "fp_9a1c2b",
"card_bin": "424242",
"last4": "4242",
"billing_country": "US",
"amount": 4999,
"currency": "usd",
"transaction_type": "PURCHASE"
}
```
Responses:
- 201 — Fraud check verdict (advisory).
- 400 — `email_required` when `email` is missing. LIVE without `EMAIL_HASH_KEY` is `fraud_hash_unavailable` (`invalid_request_error`). TEST without the key is 201, not 400.
- 401 — api_key_required — missing / invalid key.
- 403 — permission_denied (scope) or mode_not_allowed (PAYMENT_ONLY key).
### POST /v1/check/recheck
Re-evaluate with the final amount (same stateless CheckStandalone).
Identical to `POST /v1/check`; call it once the buyer confirms the final amount. Same
scope (`check:read`) and exec-mode gate. No state is kept between /check and /recheck.
Request body (required):
Same body as POST /v1/check, re-run with the final amount. Missing `email` is a 400 `email_required`.
Fields:
- `email` (string, required)
- `ip` (string, optional)
- `fingerprint` (string, optional)
- `card_bin` (string, optional)
- `bin` (string, optional)
- `last4` (string, optional)
- `billing_country` (string, optional)
- `amount` (integer, optional) — The final amount, after shipping and tax.
- `currency` (string, optional)
- `transaction_type` (string, optional)
Example:
```json
{
"email": "buyer@example.com",
"amount": 5200,
"currency": "usd"
}
```
Responses:
- 201 — Fraud check verdict (advisory) — see POST /v1/check.
```json
{
"object": "fraud_check",
"status": "flagged",
"action": "review",
"requires_3ds": true,
"reasons": [
"CARD_VELOCITY_1M"
],
"risk_score": 60,
"check_id": "b099ecb2-db7f-490b-9b07-a0f49c75c9f2"
}
```
- 400 — email_required.
- 403 — permission_denied / mode_not_allowed.
### POST /v1/report
Report a BLOCK / UNBLOCK to the merchant blocklist (server-only).
Push a fraud decision to the merchant's own blocklist. `action` ∈
`BLOCK | UNBLOCK | BLOCK_IP | UNBLOCK_IP`. Email actions require `email` (hashed at the
edge); IP actions require `ip`. Proxies to `ScoringService/AddToBlocklist` or
`RemoveFromBlocklist`.
Requires scope `report:write`, which is server-only — a publishable key can never call
it. No exec-mode gate (this is neither the charge nor the fraud-eval flow).
Request body (required):
The email actions need `email`, the IP actions need `ip`. Sending the wrong pair is a 400.
Fields:
- `action` (string, required, one of BLOCK | UNBLOCK | BLOCK_IP | UNBLOCK_IP) — Matched case-insensitively, so `block` works; these are the canonical spellings. Anything else is a 400 `invalid_action`.
- `email` (string, optional) — Required for BLOCK / UNBLOCK. Hashed at the edge; the list returns hashes only.
- `ip` (string, optional) — Required for BLOCK_IP / UNBLOCK_IP.
- `reason` (string, optional)
Example:
```json
{
"action": "BLOCK",
"email": "fraud@evil.com",
"reason": "chargeback"
}
```
Responses:
- 201 — Report applied.
```json
{
"object": "fraud_report",
"status": "ok",
"action": "blocked",
"type": "email"
}
```
- 400 — invalid_action / email_required / ip_required.
- 403 — permission_denied — a publishable key or missing report:write.
### GET /v1/blocklist
List blocklist entries for local SDK cache sync (hashed emails only).
Paginated blocklist for a developer's offline cache. Email entries expose only the
HMAC hash — plaintext is never returned. IP values are returned as stored.
Proxies to `ScoringService/ListMerchantBlocklist` (tenant-scoped). The operator
`ListBlocklist` RPC is admin-only and is not this path.
Requires scope `blocklist:read` (publishable keys are denied).
Parameters:
- `type` (query, optional, string) — Optional type filter (EMAIL | IP | CARD_BIN | DEVICE).
- `cursor` (query, optional, string) — Opaque page cursor from the previous response. This endpoint pages with `cursor` and `limit`, not `starting_after`, and its `limit` defaults to 100 with a ceiling of 500.
- `limit` (query, optional, integer) — Page size, clamped 1..500 (default 100).
Responses:
- 200 — Paginated blocklist.
```json
{
"object": "list",
"data": [
{
"object": "blocklist_entry",
"id": "a46d7680-037e-4983-9ca2-af0e04deeb29",
"type": "email",
"value": "9f2c...hashed...e1",
"reason": "chargeback",
"expires_at": null,
"created_at": "2026-07-19T10:59:58.290055Z",
"is_global": false
},
{
"object": "blocklist_entry",
"id": "b7e1c2d0-11aa-4f00-9c44-0a1b2c3d4e5f",
"type": "ip",
"value": "198.51.100.7",
"reason": "probe",
"expires_at": null,
"created_at": "2026-08-31T12:00:00.000Z",
"is_global": false
}
],
"has_more": false
}
```
- 403 — permission_denied — missing blocklist:read. A key that has the scope is a merchant principal and must 200.
### POST /v1/customers/{id}/three_ds
Turn on Require 3DS for a customer.
Turns the customer's Require 3DS flag on, exactly as the dashboard's control does:
from the next charge on, every card charge for this customer is stepped up to 3DS
until the flag is cleared. Setting it again is harmless and answers the same state,
so no `Idempotency-Key` is needed. The response is the flag read back after the
write, not an echo of the request.
The write is recorded against your API key (source `API`), so it can be told apart
from a dashboard change. ACH, Apple Pay and Google Pay with a network token are
never stepped up, whatever the flag says (the wallet's own cryptogram stands).
Requires `fraud:write`, a server-side scope (publishable keys are refused).
Request body:
Fields:
- `reason` (string, optional) — Why the flag was set, stored with it. Optional. Any other field is a 400 `parameter_unknown`.
Example:
```json
{
"reason": "Chargeback on file"
}
```
Responses:
- 200 — The flag after the write.
```json
{
"object": "customer_three_ds",
"customer": "cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10",
"require_3ds": true,
"livemode": true
}
```
- 400 — `parameter_unknown` (a field other than `reason`) or `parameter_invalid` (`reason` not a string, or longer than 500).
- 403 — A publishable key, or a key without `fraud:write` (`permission_denied`).
- 404 — `resource_missing` — no such customer for this account and mode. Nothing is written.
### GET /v1/customers/{id}/three_ds
Read a customer's Require 3DS flag.
The same flag the dashboard shows under **Restrictions** on the customer's page and
sets with **Require 3DS** / **Require 3D Secure**. While it is on, every card charge
for this customer is stepped up to 3DS. `require_3ds` is true whether the flag was
set by you, by the dashboard or by a fraud detector — it is the value the charge
path reads. TEST and LIVE keep separate flags; the key decides which one you read.
Answers only for this flag. A fraud rule or a Payment Workflow route can still ask
for 3DS on a charge when this reads false — see `GET /v1/fraud/rules` and
`GET /v1/payment_workflow/routes`.
Requires `fraud:read` (publishable keys are denied).
Responses:
- 200 — The current flag.
```json
{
"object": "customer_three_ds",
"customer": "cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10",
"require_3ds": true,
"livemode": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks `fraud:read` (`permission_denied`).
- 404 — `resource_missing` — no such customer for this account and mode.
### DELETE /v1/customers/{id}/three_ds
Clear a customer's Require 3DS flag.
Clears the flag, as **Turn off** under **Restrictions** does in the dashboard —
whoever set it, including a fraud detector. Clearing a flag that is not set answers
the same cleared state.
This lifts only the flag. It is not an override: a fraud rule or a Payment Workflow
route that asks for 3DS still steps the charge up, because a platform rule always
outranks a flag or a request. If `require_3ds` comes back true, something set it
again between the write and the read.
Requires `fraud:write`, a server-side scope (publishable keys are refused).
Responses:
- 200 — The flag after the write.
```json
{
"object": "customer_three_ds",
"customer": "cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10",
"require_3ds": false,
"livemode": true
}
```
- 403 — A publishable key, or a key without `fraud:write` (`permission_denied`).
- 404 — `resource_missing` — no such customer for this account and mode. Nothing is written.
### POST /v1/fraud/three_ds_policy
Turn the risk-score 3DS step-up off, on, or move its band.
If you decide 3DS yourself with `three_ds.request`, send `risk_step_up.enabled:
false` so your rules are the only trigger. The change applies from the next charge.
The body sets the whole band: a bound you leave out goes back to its default (70 or
95), so the request alone says what applies. Sending the same body again is harmless
and answers the same setting, so no `Idempotency-Key` is needed. The response is
the setting read back after the write.
Turning it off lifts only the score band. A charge is still stepped up when a fraud
rule asks for it, the customer is marked Require 3DS, or the route has Attempt 3DS
on; and a score above your decline threshold still declines.
Requires `fraud:write`, a server-side scope (publishable keys are refused).
Request body (required):
Fields:
- `risk_step_up` (object, required)
- `risk_step_up.enabled` (boolean, required) — Step a charge up to 3DS when its risk score is inside the band.
- `risk_step_up.min_score` (integer, optional, min 0) — Lowest score in the band. Left out, 70.
- `risk_step_up.max_score` (integer, optional, min 0) — Highest score in the band. Left out, 95. Must not be below `min_score`.
Example:
```json
{
"risk_step_up": {
"enabled": false
}
}
```
Responses:
- 200 — The setting after the write.
```json
{
"object": "three_ds_policy",
"risk_step_up": {
"enabled": false,
"min_score": 70,
"max_score": 95
},
"livemode": true
}
```
- 400 — `parameter_unknown` (any other field) or `parameter_invalid`: `risk_step_up` missing, `enabled` not a boolean, a score that is not a whole number from 0 to 100, or `min_score` above `max_score` (the default counts for a bound you leave out). Nothing is written.
- 403 — A publishable key, or a key without `fraud:write` (`permission_denied`).
### GET /v1/fraud/three_ds_policy
Read the risk-score 3DS step-up.
Our fraud engine gives every card charge a risk score from 0 to 100. While
`risk_step_up.enabled` is true, a charge whose score falls between `min_score` and
`max_score` (inclusive; 70 and 95 unless you changed them) is stepped up to 3DS,
whatever your own rules decide. These are the values the next charge uses. TEST and
LIVE keep separate settings; the key decides which one you read.
Requires `fraud:read` (publishable keys are denied).
Responses:
- 200 — The current setting.
```json
{
"object": "three_ds_policy",
"risk_step_up": {
"enabled": true,
"min_score": 70,
"max_score": 95
},
"livemode": true
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks `fraud:read` (`permission_denied`).
### GET /v1/fraud/rules
List the fraud rules that apply to your account (read-only).
The rules on the dashboard's Fraud Rules page, in evaluation order (`priority`
ascending). Each carries its `conditions` (the `field` / `operator` / `value`
triples the engine evaluates, joined by `logical_operator`), its `action` and
whether it is `enabled`. When several rules match one charge the strongest action
wins: DECLINE, then REVIEW, then STEP_UP_3DS, then ROUTE, then APPROVE.
`requires_3ds` is true for a rule whose action is `STEP_UP_3DS`. `global: true`
marks a platform rule you cannot edit; it applies to every account. Every Payment
Workflow route keeps one rule in step with the canvas — `route` names it, and
`null` means a rule you authored.
**Read-only.** Creating, editing or disabling rules is not available from the API:
it is a large security surface (a leaked key could switch your fraud rules off)
and stays in the dashboard. Requires `fraud:read` (publishable keys are denied).
Parameters:
- `cursor` (query, optional, string) — Opaque page cursor from the previous response's `next_cursor`. This endpoint pages with `cursor`, not `starting_after`.
- `limit` (query, optional, integer) — Page size, clamped 1..100 (default 10).
Responses:
- 200 — One page of rules. `next_cursor` is omitted on the last page.
```json
{
"object": "list",
"data": [
{
"object": "fraud_rule",
"id": "0f8b6c2e-3a7d-4e1f-9b2c-5d6e7f8a9b0c",
"name": "High risk score",
"description": "",
"logical_operator": "AND",
"created_at": "2026-10-01T09:00:00Z",
"updated_at": "2026-10-02T11:30:00Z",
"conditions": [
{
"field": "risk_score",
"operator": "gte",
"value": 80
}
],
"action": "STEP_UP_3DS",
"requires_3ds": true,
"route": null,
"score": 0,
"priority": 10,
"enabled": true,
"global": false
}
],
"has_more": false
}
```
- 400 — A `cursor` this endpoint did not issue.
- 403 — The key lacks `fraud:read` (`permission_denied`).
### GET /v1/payment_workflow/routes
List your Payment Workflow routes (read-only).
The routes drawn on the dashboard's Payment Workflow canvas, in the order they are
evaluated (`order`, from 1). The catch-all "All other conditions" branch is the
route with `catch_all: true` and is always last. Each route carries its
`conditions`, its outcome (`action`: `route` through `processor` and then
`fallback_processors`, or `decline`) and its gates.
`require_3ds` is the route's 3DS toggle (labelled **Attempt 3DS** on the canvas):
when the winning route has it on, the charge is stepped up. `require_cvv` /
`require_avs` decline a charge whose CVV or AVS check fails. `min_amount` /
`max_amount` are integer cents; 0 means no bound. Routes have no on/off switch;
every route returned is saved in the workflow. The whole list comes back in one
response.
**Read-only.** Editing routes from the API is not available: routing decides
where money goes, and opening it to API keys needs a separate decision. Requires
`fraud:read` (publishable keys are denied).
Responses:
- 200 — Every route, in evaluation order.
```json
{
"object": "list",
"data": [
{
"object": "payment_workflow_route",
"key": "high_value",
"name": "High value",
"catch_all": false,
"conditions": [
{
"field": "amount",
"operator": "gte",
"value": 50000
}
],
"action": "route",
"processor": "nmi_primary",
"fallback_processors": [
"nmi_backup"
],
"require_3ds": true,
"require_cvv": true,
"require_avs": false,
"min_amount": 0,
"max_amount": 0,
"order": 1
},
{
"object": "payment_workflow_route",
"key": "default",
"name": "default",
"catch_all": true,
"conditions": [],
"action": "route",
"processor": "nmi_primary",
"fallback_processors": [],
"require_3ds": false,
"require_cvv": false,
"require_avs": false,
"min_amount": 0,
"max_amount": 0,
"order": 2
}
],
"has_more": false
}
```
- 403 — The key lacks `fraud:read` (`permission_denied`).
### GET /v1/3ds/config
Evervault SDK init data + 3DS readiness for the key's environment.
Returns the 3DS provider client config (team/app id, provider) and an `enabled` flag. `enabled` is true when the platform's 3DS provider is configured for the key's environment (test or live); it is a per-environment platform flag, with no per-merchant or acquirer check. Test mode is configured. When `enabled` is false, `POST /v1/3ds/sessions` and `POST /v1/3ds/retry-decline` return 409 `three_ds_not_enabled` — they do not call the 3DS provider. Surface the flag to the SDK before you try to open a session. Scope `threeds:read`; exec-mode `atomic | payment_only`.
**Wallet Pay.** The same response carries `apple_pay_enabled`, `google_pay_enabled` and `evervault_merchant_id`. A wallet is on only when the platform flag, the per-merchant toggle **and** a provisioned merchant id all agree; otherwise the flags are `false` and the id is empty.
Treat these as **fail-closed**: mount a wallet button only on a literal `true`. An older response that omits the fields must mount nothing rather than assume availability. These flags say what the *account* has enabled — whether the buyer's device can actually pay is decided at mount time by the SDK.
Responses:
- 200 — 3DS client config + wallet enablement.
```json
{
"object": "three_ds_config",
"team_id": "team_...",
"app_id": "app_...",
"provider": "evervault",
"enabled": true,
"apple_pay_enabled": false,
"google_pay_enabled": false,
"evervault_merchant_id": ""
}
```
- 401 — api_key_required / api_key_invalid — missing or bad key.
- 403 — permission_denied (missing `threeds:read`) or mode_not_allowed (a `fraud_only` key cannot access the 3DS surface).
### POST /v1/3ds/sessions
Open a 3D Secure session for the authenticated merchant.
Creates a 3DS session against the merchant's acquirer. When `GET /v1/3ds/config` reports `enabled: false`, this route returns **409** `three_ds_not_enabled` and does not call the 3DS provider. The billed merchant + env are derived from the API-key **principal**, never the request body — a `merchant_account_id` in the body is ignored, so a developer can never open (and bill) a session against another merchant's acquirer. The card must be an Evervault `ev:` token (or omitted for a browser-SDK-supplied card); a raw PAN is rejected. Idempotent on the `Idempotency-Key` header — a replay returns the same session. Scope `threeds:create` (server-side); exec-mode `atomic | payment_only`.
Parameters:
- `Idempotency-Key` (header, required, string) — Replay-safe key; a duplicate returns the same session.
Request body (required):
Fields:
- `amount` (integer, required) — Minor units. Missing is a 400.
- `currency` (string, optional) — ISO 4217.
- `card` (object, optional)
- `card.number` (string, optional) — An `ev:` token only - a raw PAN is refused.
- `card.exp_month` (optional) — String or number.
- `card.exp_year` (optional) — String or number.
- `card.bin` (string, optional)
- `card.last4` (string, optional)
- `customer` (object, optional)
- `customer.email` (string, optional)
- `customer.ref` (string, optional)
- `request` (string, optional, one of automatic | any | challenge | challenge_mandated) — The challenge preference the issuer is sent — the vocabulary of `three_ds.request` on a charge: `any` (EMV 01, no preference), `challenge` (03, challenge requested), `challenge_mandated` (04). `automatic` / absent sends no preference, as before; the issuer decides.
- `idempotency_key` (string, optional)
Example:
```json
{
"amount": 5000,
"currency": "usd",
"card": {
"number": "ev:RFVC1234567890123456",
"exp_month": "12",
"exp_year": "2030",
"bin": "411111",
"last4": "1111"
},
"customer": {
"email": "buyer@example.com"
},
"request": "challenge"
}
```
Responses:
- 201 — Session opened (status `pending` until the challenge completes).
```json
{
"object": "three_ds_session",
"id": "ev3ds_569113638",
"status": "pending",
"enrolled": true,
"three_ds_version": "2.2.0"
}
```
- 400 — parameter_invalid — amount missing, unsettleable currency, a raw-PAN card.number (must be an `ev:` token), or an unknown `request` (`param: request`).
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (missing `threeds:create`, e.g. a publishable key) or mode_not_allowed (a `fraud_only` key).
- 409 — three_ds_not_enabled — GET /v1/3ds/config reports `enabled: false` for this merchant. No provider session is opened.
### GET /v1/3ds/sessions/{id}
Retrieve the authoritative 3D Secure result.
Returns the **authoritative** authentication result for a session — `cavv`, `eci`, `ds_trans_id`, `cardholder_auth`, `liability_shift` — fetched from the 3DS provider, NOT any browser-submitted value. This response is the sole proof of authentication; pass its session id to `POST /v1/charges` under `three_ds.session_id` to bind it to a charge. Tenancy-scoped: a session id that isn't this key's merchant 404s. Scope `threeds:read`; exec-mode `atomic | payment_only`.
`status` is `pending`, `authenticated` or `failed`. A charge proceeds only on `authenticated: true` with a `cavv`; any other session is refused by `POST /v1/charges`. A failed session names why in `failure_reason`, empty otherwise: `authentication_failed` (the cardholder did not pass), `canceled` (closed the challenge), `abandoned` (the challenge timed out), `rejected` (the issuer refused to authenticate), `card_not_enrolled` (the card cannot be authenticated: not enrolled, unknown to the issuer, or no supported 3DS version), `challenge_required` (a challenge was needed and the session was opened not to show one) or `processing_error` (a 3DS server, directory or issuer-ACS fault).
Parameters:
- `id` (path, required, string) — The 3DS session id returned by create.
Responses:
- 200 — Authoritative session result.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (missing `threeds:read`) or mode_not_allowed (`fraud_only`).
- 404 — resource_missing — no such session for this merchant (tenancy-scoped).
### GET /v1/3ds/sessions/{id}/status
Poll 3D Secure liveness from the browser (publishable-safe).
The **browser** view of a session: is the challenge still running, did it finish, did it fail. Scope `none`, so a publishable key can read it — which the authoritative `GET /v1/3ds/sessions/{id}` deliberately cannot be, since that one returns `cavv` and a `pk_` ships in your page source.
Returns liveness only: `status`, `authenticated`, and `cryptogram_present` — a **boolean**, never the cryptogram — plus `failure_reason` on a failed session (the same values as the authoritative route), so a page can tell the buyer something true. The authentication value stays server-side; `POST /v1/charges` fetches it by session id when you pass `three_ds.session_id`.
This is what the JS SDK's `handle3DS()` polls. Reach for it directly only if you drive the challenge yourself from the browser. From a server, use the authoritative endpoint instead — it carries the proof.
Tenancy-scoped exactly like the authoritative route: a session id that isn't this key's merchant 404s. Exec-mode `atomic | payment_only`.
Parameters:
- `id` (path, required, string) — The 3DS session id returned by create.
Responses:
- 200 — Session liveness. No authentication value.
```json
{
"object": "three_ds_session",
"id": "ev3ds_sessionA",
"status": "authenticated",
"authenticated": true,
"cryptogram_present": true,
"failure_reason": ""
}
```
- 401 — api_key_required / api_key_invalid.
- 403 — mode_not_allowed (a `fraud_only` key cannot touch the 3DS surface).
- 404 — resource_missing — no such session for this merchant (tenancy-scoped).
### POST /v1/3ds/retry-decline
Re-open a 3DS session after a soft gateway decline.
A soft-decline recovery: re-opens a 3DS session for the original amount so a step-up can be attempted before giving up on the sale. The original charge is looked up first: an unknown `original_transaction_id` is **404** `resource_missing` and no provider session is opened. When the charge exists but `GET /v1/3ds/config` reports `enabled: false`, the route is **409** `three_ds_not_enabled`. Idempotent on the `Idempotency-Key` header (default derived from `original_transaction_id`) — a duplicate retry returns the same session and never double-bills the acquirer. Same principal-derived tenancy as create. Scope `threeds:create`; exec-mode `atomic | payment_only`.
Parameters:
- `Idempotency-Key` (header, optional, string) — Defaults to `original_transaction_id` when omitted.
Request body (required):
The create body plus the decline context. A repeat returns the SAME session rather than minting a second one. Unknown `original_transaction_id` is 404 `resource_missing` before 3DS readiness is checked.
Fields:
- `amount` (integer, required) — Minor units, the original amount.
- `currency` (string, optional)
- `card` (object, optional)
- `card.number` (string, optional) — An `ev:` token only.
- `card.exp_month` (optional) — String or number.
- `card.exp_year` (optional) — String or number.
- `card.bin` (string, optional)
- `card.last4` (string, optional)
- `customer` (object, optional)
- `customer.email` (string, optional)
- `customer.ref` (string, optional)
- `original_transaction_id` (string, required) — The declined charge. Also accepted as the idempotency key when no header is sent.
- `decline_code` (string, optional)
- `request` (string, optional, one of automatic | any | challenge | challenge_mandated) — The challenge preference, as on `POST /v1/3ds/sessions`.
- `idempotency_key` (string, optional)
Example:
```json
{
"amount": 5000,
"currency": "usd",
"original_transaction_id": "txn_9f2c",
"decline_code": "05",
"card": {
"number": "ev:RFVC1234567890123456",
"exp_month": "12",
"exp_year": "2030"
}
}
```
Responses:
- 201 — Retry session opened.
```json
{
"object": "three_ds_session",
"id": "ev3ds_569144422",
"status": "pending",
"enrolled": true,
"three_ds_version": "2.2.0",
"retryable": true,
"original_transaction_id": "txn_9f2c",
"decline_code": "05"
}
```
- 400 — parameter_invalid — amount/currency/card as for create.
- 401 — api_key_required / api_key_invalid.
- 403 — permission_denied (missing `threeds:create`) or mode_not_allowed (`fraud_only`).
- 404 — resource_missing — no such `original_transaction_id` for this merchant and env-mode. Checked before 3DS readiness, so a fake TXN is 404 even when 3DS is disabled.
- 409 — three_ds_not_enabled — the original charge exists but GET /v1/3ds/config reports `enabled: false`. No provider session is opened.
### GET /v1/stores/stats
Aggregate storefront counters + revenue.
Roll-up across the merchant's storefronts: total/active/inactive stores, product count,
order count, revenue. Proxies `CommerceCheckoutService/GetStoreStats`. Requires
`stores:read`. Money in minor units. Declared before `/v1/stores/{id}` so "stats" is
not read as a store id.
Responses:
- 200 — store_stats
```json
{
"object": "store_stats",
"total_stores": 3,
"active_stores": 2,
"inactive_stores": 1,
"total_products": 47,
"total_orders": 0,
"total_revenue": 0
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `stores:read` scope (`permission_denied`).
### GET /v1/stores
List storefronts.
The merchant's storefronts (the "markets" in product terms). Cursor-paged with
`starting_after` (same shape as GET /v1/charges). `active` is an optional filter; an
absent one lists both. Read-only, scoped to the key. Requires `stores:read`. Proxies
`CommerceCheckoutService/ListStores`.
Parameters:
- `limit` (query, optional, integer, default 10, max 100) — Page size. Defaults to 10 and is capped at 100.
- `starting_after` (query, optional, string) — Cursor for the next page: the id of the last row you received. Omit it on the first request, and keep paging while `has_more` is true.
- `active` (query, optional, boolean) — Send `true` for open storefronts only or `false` for closed ones. Omit for both.
Responses:
- 200 — List
```json
{
"object": "list",
"data": [
{
"id": "store_9x2",
"object": "store",
"slug": "acme-shop",
"name": "Acme Shop",
"active": true,
"product_count": 12
}
],
"has_more": false,
"next_cursor": null
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `stores:read` scope (`permission_denied`).
### GET /v1/stores/{id}
Fetch a storefront.
Proxies `CommerceCheckoutService/GetStore`. Scoped to the key's merchant. Requires
`stores:read`.
Parameters:
- `id` (path, required, string) — The store id.
Responses:
- 200 — store
```json
{
"id": "store_9x2",
"object": "store",
"slug": "acme-shop",
"name": "Acme Shop",
"description": "Everything Acme",
"active": true,
"show_prices": true,
"product_count": 12
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `stores:read` scope (`permission_denied`).
- 404 — No such store for this key's merchant and env-mode (`resource_missing`).
### GET /v1/metrics
Sales / KPI roll-up.
The same KPI tiles the dashboard shows: gross / approved / net volume, refunds,
approval rate and transaction counts. Proxies `AnalyticsService/GetKpiTiles`. Requires
`metrics:read`. `period` is today|7d|30d|90d|all (default 30d); a custom window is
`start_date` / `end_date` (YYYY-MM-DD). Money is in minor units; `approval_rate` is a
0..1 fraction and `approval_rate_bps` the same figure in basis points.
Parameters:
- `period` (query, optional, string, one of today | 7d | 30d | 90d | all, default 30d) — Preset window, defaulting to `30d`. Ignored when `start_date` and `end_date` are supplied.
- `start_date` (query, optional, string) — Start of a custom window, `YYYY-MM-DD`. Use with `end_date` instead of `period`.
- `end_date` (query, optional, string) — End of a custom window, `YYYY-MM-DD`. Use with `start_date` instead of `period`.
Responses:
- 200 — metrics
```json
{
"object": "metrics",
"gross_volume": 4820000,
"approved_volume": 4510000,
"net_volume": 4390000,
"refunds": 120000,
"approval_rate_bps": 9356,
"approval_rate": 0.9356,
"tx_count": 512,
"approved_count": 479,
"declined_count": 33,
"refund_count": 8,
"dispute_count": 2
}
```
- 401 — Missing or invalid API key (`authentication_required`).
- 403 — The key lacks the `metrics:read` scope (`permission_denied`).
## Error format
All errors: { "error": { "type", "code", "message", "decline_code"? } }. Declines return HTTP 402 (type card_error); validation 400 (parameter_invalid); auth 401 (authentication_required); scope 403 (permission_denied); not found 404 (resource_missing); idempotency conflict 409 (idempotency_in_progress); rate limit 429 (rate_limit_exceeded).
## Idempotency
Send an Idempotency-Key header on POST /v1/payment_intents, POST /v1/charges and POST /v1/refunds. Retries replay the original result (X-Idempotency-Replayed: true). Stored 24h.
## Pagination
Cursor-based: limit (max 100), starting_after= (an opaque token, never an object id; GET /v1/events takes a page number). Response { object:'list', data:[...], has_more:bool, next_cursor }. No total.
## Rate limits
Per key: 200/s, 5000/min, 100000/hr. POST /v1/charges, POST /v1/refunds (each in its own window) and POST /v1/test/tokens: 5/min. Test keys get 10x. 429 carries Retry-After.