# 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](./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](./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](./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`](./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](./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](./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_<uuid>` 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](./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](./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](./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`](./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](./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`](./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](./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`](./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](./07-3ds.md). With the [Payment
Element](./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](./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](./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](./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](./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](./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](./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](./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](./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](./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](./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=…`](./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`](./19-events.md).

An unrecognized query parameter is silently ignored rather than rejected, so a mistyped
filter returns unfiltered data. See [Pagination](./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
}
```
