Charges
View as MarkdownA 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 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. |
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. |
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. |
wallet | object | one | An Apple Pay / Google Pay credential. Do not also send three_ds; it is rejected. |
customer | object | No | See customer. |
billing_address | object | No | See address. 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. |
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. 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. |
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. 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 onPOST /v1/charges: | You sent | Use on this route | |---|---| |receipt_email|customer.email| |billing|billing_address| |order_reference|descriptionand/ormetadata, create has noorder_reference| Those names are valid elsewhere:receipt_emailandreferenceonPOST /v1/payment_intents;billing(notbilling_address) on confirm. Copy-pasting a Stripe charge body, or an intent body, into this endpoint is the usual first 400.
amountis required. Provide exactly one instrument (card,payment_method, orwallet). Everything else is optional, includingcurrency(defaults tousd). 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_tokennever 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.
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.
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:
{
"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 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, 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).
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 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#
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/:idreturnsbilling_detailsfrom what we stored: the customer's name, email and phone, and the billing address withline2folded intoline1. It returns yourmetadataas stored, and noshipping, because the transaction record does not carry it; keep your own copy of the shipping address, keyed on the returnedid, if you need it later. The card (last4) also survives a read. A charge created before metadata was stored readsmetadata: {}.
Send a stable
Idempotency-Keyheader 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.
| Topic | Rule |
|---|---|
| PAN | Tokens only (ev: or wp:v1:); raw digits rejected |
| IP | Not a body field on charge; see below |
| 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
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 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 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#
{
"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/chargesandGET /v1/charges/{id}returncard,billing_detailsandmetadatafrom what we stored, so the brand, last4, BIN and your metadata are there, butshippingis 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 asnull, so use optional chaining instead of comparing againstnull.
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.
{
"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. With the Payment Element 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). 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:
{
"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
requestsays, soanycannot soften a challenge your risk rules demanded. - It needs a card the buyer is paying with now: a card entered for this payment
(
card.numberas anev:token), or a saved card sent with"initiated_by": "customer". On a merchant-initiated saved-card charge, a gateway vault id, awp:envelope or a wallet it is a 400parameter_invalidwithparam: three_ds.request. Omit it to charge those as before.
On a payment intent you set the same field at create; the Payment Element runs the challenge.
3. Error (declined or invalid)#
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "The charge was declined."
}
}
See the error envelope 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.
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.
pendingon create. When the gateway has not told us in time whether the card was charged, the create answersstatus: "pending". That answer is not stored under yourIdempotency-Key: retry with the same key to learn the outcome, never with a new one. See Idempotency.
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.
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.messageto the shopper and branch your retry logic onerror.code. See Errors for retry guidance.
Retrieve a charge#
GET /charges/:id. Requires payments:read.
curl https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3 -H "x-api-key: sk_live_xxx"
{
"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). 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}/riskbehindpayments:read. See the API reference.
Not on the browser payload. The Payment Element's
onSuccessis a named receipt subset:amount,currency,status,paid,captured,amount_captured,amount_refunded,auth_code,processor_transaction_id(the gateway id / NMItransactionid),statement_descriptor,description,payment_method_type,metadata,created,billing_details,card(brand,last4,exp_month,exp_year), andfailure_code/failure_messageon a decline.mid,processor,processor_label,network_transaction_id,arn,rrn, settlement, and the AVS/CVV verdicts stay onGET /v1/charges/{id}behind your secret key. That payload lands in a page the buyer can read. See Payment Element.
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:
curl "https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3?expand[]=attempts" \
-H "x-api-key: sk_live_xxx"
{
"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 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_labelorattempted_at, and with adecline_codeon 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=…. 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:
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.
An unrecognized query parameter is silently ignored rather than rejected, so a mistyped filter returns unfiltered data. See Pagination.
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.
curl "https://api.worthypayments.com/v1/charges?limit=20&status=succeeded" -H "x-api-key: sk_live_xxx"
{
"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
}
