Charges

View as Markdown

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 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#

FieldTypeRequiredNotes
amountintegerYesCents. Minimum 50. $49.99 → 4999.
currencystringNoISO 4217, e.g. "usd". Defaults to usd when omitted.
cardobjectoneA new card, as tokens. See below.
payment_methodstring | objectoneId of a saved card, or a payment-method object, to charge instead of card.
recurringbooleanNotrue 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_bystringNoSaved cards only: "customer" when the cardholder is paying now (on your checkout), "merchant" (the default) when you charge without them. See saved cards.
walletobjectoneAn Apple Pay / Google Pay credential. Do not also send three_ds; it is rejected.
customerobjectNoSee customer.
billing_addressobjectNoSee address. Feeds AVS, fraud scoring, and 3-D Secure.
shipping_addressobjectNoAn address plus name and phone.
deviceobjectNo{ fingerprint?, kount_session_id? }. Improves scoring; fails open if missing.
device_sessionstringNoA 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_dsobjectNo{ 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.
descriptionstringNoShown 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_methodstringNo"automatic" (default) or "manual". See Authorize & Capture.
captureboolean | stringNoLegacy alias: false / "manual" → manual capture. Prefer capture_method.
risk_decision_tokenstringNoSigned receipt from a prior POST /check. Optional; strict if present; never pre-approves.
metadataobjectNoYour 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.

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.

FieldTypeRequiredNotes
numberstringYesAn ev:… token. A raw PAN is rejected.
cvvstringYesAn ev:… token. The field is cvv everywhere, including the POST /test/tokens response, nothing to map.
exp_monthstring | numberYesTwo digits, e.g. "12". Required on the ev: rail (expiry is not inside the PAN token).
exp_yearstring | numberYesTwo 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.

FieldTypeRequiredNotes
wp_tokenstringYesA wp:v1: envelope. Leave number and cvv unset.
exp_monthstring | numberNoOptional on this rail. Expiry is sealed inside the envelope; the edge accepts omission (defaults to 0 on the wire meta).
exp_yearstring | numberNoOptional on this rail for the same reason.

Both rails share the rest of the object:

FieldTypeRequiredNotes
namestringNoCardholder name.
binstringNoFirst 6 to 8 digits of the PAN (never the full PAN). Used for routing and fraud.
last4stringNoLast 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:

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 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

FieldTypeNotes
emailstringA valid email address.
namestringFull name. If first_name/last_name are absent it's split on the last space.
first_namestringTakes precedence over name.
last_namestring
phonestring

address objects

Both billing_address and shipping_address use these fields. shipping_address adds name and phone.

FieldTypeNotes
line1stringStreet address.
line2stringApartment, suite, etc. Folded into the AVS line.
citystring
statestringState / province.
postal_codestringZIP / postcode. Used for AVS.
countrystringISO 3166-1 alpha-2, e.g. "US".
namestring(shipping only) recipient name.
phonestring(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 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.

TopicRule
PANTokens only (ev: or wp:v1:); raw digits rejected
IPNot a body field on charge; see below
3DSResubmit only three_ds.session_id; never client cryptograms
Idempotency-KeyRequired; stable per order, not a new UUID every click
risk_decision_tokenOptional; strict if present; never pre-approves the charge
Wallet + 3DSDo not combine; rejected
DeviceFingerprint / Kount improve scoring; missing fails open (sale still runs)
Decline UIShow 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#

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. 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:

SessionResponseWhat to do
Failed402 card_error / card_declined, decline_code: three_ds_failed, param: three_ds.session_idThe 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 enrolled402, decline_code: three_ds_card_not_enrolledThe card cannot be authenticated. Charge it again without three_ds if you accept no liability shift, or ask for another card.
3DS network fault402, decline_code: processing_errorStart a new attempt; it opens a new session.
Still open409 three_ds_session_incompleteThe challenge has not finished. Resubmit with the same key once it does.
Result unreadable503 three_ds_result_unavailableRetry 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.request3DS runsThe issuer is told (EMV 3DS challenge indicator)
automatic (default)when your rules aska rule-driven challenge is mandated (04)
anyalwaysno preference (01): frictionless whenever the issuer allows
challengealwaysa challenge is requested (03); the issuer may still go frictionless
challenge_mandatedalwaysa 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 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 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.

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.

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.

codeCauseRetry?
wp_token_expiredToken older than 10 minutesRe-tokenize
wp_token_replayedToken already used on a previous chargeRe-tokenize
wp_token_malformedToken failed to decrypt or validateno, fix the integration
wp_token_audience_mismatchToken minted for a different accountno, check your keys
wp_token_too_largeToken over 512 charactersno, remove extra envelope fields
raw_pan_rejectedA plain card number was sentno, tokenize first
wp_replay_store_unavailableOur outage, not your tokenyes, 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:

codedecline_codeMeaningWhat to do
card_declinedinsufficient_fundsNot enough balanceAsk for another card
card_declineddo_not_honorGeneric issuer declineAsk for another card
card_declinedpickup_cardThe 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_declinedfraudulentIssuer suspects fraudDo not retry this card
card_declinedprocessing_errorTransient issuer or gateway error; nothing was chargedRetry after a short backoff, with a new Idempotency-Key
card_declinedduplicate_transactionThe gateway refused it as a duplicate of a recent identical paymentDo not retry. Check GET /charges first
card_declinedthree_ds_failedThe three_ds.session_id session did not authenticate; nothing was chargedLet the customer retry 3DS or use another card, under a new Idempotency-Key
card_declinedthree_ds_card_not_enrolledThe card cannot be authenticated with 3-D Secure; nothing was chargedCharge without three_ds (no liability shift) or ask for another card
card_declinedinvalid_merchant / invalid_transactionThe gateway refused the request (account setup, or a transaction it does not allow)Do not retry; contact support
expired_card—Card expiredAsk for updated expiry / new card
incorrect_cvc—CVV mismatchAsk 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 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 fieldWhat it isAfter the sale
CodeThe 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 codesRaw 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 codeCharge-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.

FieldMeaning
processor / processor_labelWhich acquirer ran the sale, from the table above. processor = Admin → Acquirers → Code. processor_label is that MID's display name; omitted when empty.
midThe 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_idThe gateway's own id for the transaction (NMI transactionid). Also on the Payment Element receipt as charge.processor_transaction_id.
network_transaction_idThe card network's id. Keep it: a card-on-file sequence references the first payment by this.
response_code / response_messageThe gateway's result. 100 is an approval on NMI.
processor_response_code / processor_response_textThe processor's verbatim pair, when it differs from the gateway's.
avs_result / cvv_resultAddress and security-code verdicts, MATCH, NO_MATCH, NOT_PROVIDED…
avs_result_code / cvv_result_codeThe raw issuer letters behind those verdicts (Y, M, N…).
statement_descriptorThe 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_atWhen each state actually happened.
capture_beforeOn 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_reasonexpired 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:

Chargeoutcome.network_statusoutcome.typeoutcome.seller_message
succeeded / requires captureapproved_by_networkauthorizedPayment complete.
failed: the issuer or network declined itdeclined_by_networkissuer_declinedThe bank declined this payment.
failed: the gateway refused it before the card network saw itnot_sent_to_networkinvalidThe 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 callnot_sent_to_networkblockedThis payment was blocked by fraud screening.
pendingpendingpendingPayment is processing.
cancelednot_sent_to_networkinvalidThe 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.

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.

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 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.

FieldMeaning
processor / processor_labelThe processor the attempt ran on, in the same words as the charge's own processor / processor_label. Never an internal id.
outcomeapproved, declined, error (the gateway refused the request or answered something unusable), timed_out (no answer came back) or pending (still in flight).
reconciledtrue 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_codeThe normalized decline on a declined attempt, the same vocabulary as failure_code.
response_codeThe 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.
cascadetrue on every attempt after the first: Payment Workflow retried the payment there.
finalThe attempt that produced the charge's result. Always the last one.
attempted_at / duration_msWhen 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:

FieldMeaningWhen present
amount_refundedCents refunded so far, across every partial refundOnce anything has been refunded
amount_capturedCents actually captured, differs from amount only after a partial captureRetrieve 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:

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.

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.

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
}