Customers & saved cards
View as MarkdownCreate customers and save their cards so you can charge a returning customer server-side without re-collecting card details.
POST /v1/customers
GET /v1/customers GET /v1/customers/:id POST /v1/customers/:id (update)
POST /v1/customers/:id/payment_methods
GET /v1/customers/:id/payment_methods
DELETE /v1/customers/payment_methods/:pmId
GET /v1/customers/:id/transactions
GET /v1/customers/:id/state
GET /v1/customers/external/:externalId/state
Scope: customers:read (reads), customers:write (writes, server-only, pk_ blocked).
GET /v1/customers/:id/transactions requires payments:read. It returns charges, not
customer data.
Listing one buyer's charges. GET /v1/charges takes no customer filter, so use
GET /v1/customers/:id/transactions.
-
Same shape. It returns the same row shape as
GET /v1/charges, so one parser serves both. -
Charges only. You get sales and authorizations, including those synced from your gateway. Refund, void and capture rows never appear.
-
Paging. It is cursor-paginated with
limit(1–100) andstarting_after. Pass the previous page'snext_cursorasstarting_after. -
Per-row state. Each row says what it has already given back:
amount_refundedcounts refunds made through the API and in your gateway's portal alike;refundedistrueonce that is the whole amount;disputedistruewhile a chargeback or matched alert is open on it;cardnames the card it was paid with (brand,bin,last4).
That is exactly what you need to refund a customer's past charges, for example instead of sending a payout. One customer can have paid with several cards, and a refund goes back to the card of the charge it refunds, so refund only charges on the card(s) of the person you owe. See Refund a customer's past charges.
-
Unknown customer. An unknown customer id returns an empty list.
GET /v1/customers accepts a created window in Unix seconds. See
Pagination.
Customer state: one call instead of a state machine#
curl "https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/state" \
-H "x-api-key: sk_live_xxx"
{
"object": "customer_state",
"id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "external_id": null,
"email": "ada@example.com", "name": "Ada Lovelace",
"active_subscriptions": [ { "id": "sub_...", "status": "active", "plan": "plan_gold" } ],
"payment_methods": [ { "id": "pm_...", "card": { "brand": "visa", "last4": "4242" } } ]
}
This is the whole entitlement layer. Cache it against your own user id and refresh it
when a customer or subscription webhook arrives. There is no subscription state machine to
maintain. Address it by our id via /customers/{id}/state, and keep the cus_… id
POST /v1/customers returned next to your own user id.
active_subscriptions is filtered for you, and two of those decisions are worth stating
because they are the ones every hand-written filter gets wrong:
| Status | In the list? | Why |
|---|---|---|
active, trialing | ||
past_due | Dunning is running. Cutting access at the first failed payment punishes a customer for an expired card, and the retry that succeeds arrives after they have churned. | |
canceled | until the period ends | "Cancelled" means "will not renew". They paid for the current period. Once it ends the subscription leaves the list. |
paused, pending_payment, suspended | Billing is suspended, has not started, or dunning gave up. |
It fails closed. If we cannot reach billing you get an error, never a customer with
an empty active_subscriptions. An empty list means "entitled to nothing", and returning
that during an outage would make every integrator caching this revoke access for their
whole customer base.
external_id cannot be set yet#
GET /v1/customers/external/{id}/state looks a customer up by external_id, your own id for
them. No API call sets that field today: POST /v1/customers reads only email, name,
phone and idempotency_key, and POST /v1/customers/:id does not take it either, so a
customer you create carries "external_id": null. The by-external-id route therefore
answers 404 resource_missing (param: "external_id") for any id you choose. Use
/customers/{id}/state with our id.
Create a customer#
curl https://api.worthypayments.com/v1/customers \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "email": "jane@example.com", "name": "Jane Doe", "phone": "+15555550123" }'
{ "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "object": "customer",
"email": "jane@example.com", "name": "Jane Doe", "phone": "+15555550123",
"created": 1733600000, "livemode": false, "metadata": {},
"address": null, "invoice_settings": { "default_payment_method": null } }
name is split into first/last server-side. Customers are unique per email (per merchant
and mode): an email that already has a customer returns 409 invalid_request_error with
code: "already_exists", and GET /v1/customers?email= (below) finds that customer. A
retry with the same idempotency_key (in the body; this route reads no Idempotency-Key
header) and the same email returns the customer the first call created instead. Every
customer object carries created (unix seconds),
livemode, address (null until set), and invoice_settings.default_payment_method
(null until a card is attached as default). Those keys are always present.
Update a customer#
POST /v1/customers/:id is Stripe's update (not PATCH). Idempotency-Key is
optional, omit it, or send an empty string, and the name still updates. A present
header is honoured.
curl https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "name": "Ada Byron", "address": { "line1": "1 Ada St", "city": "London", "country": "GB" } }'
Find a customer by email#
GET /v1/customers?email= is an exact match (case-insensitive, plus-addresses are
distinct). An unknown email is an empty list, not a 404:
curl "https://api.worthypayments.com/v1/customers?email=buyer@example.com" \
-H "x-api-key: sk_test_xxx"
{ "object": "list", "data": [ { "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "object": "customer",
"email": "buyer@example.com", "name": "Ada Lovelace", "phone": "+15551230000",
"created": 1733600000, "livemode": false, "metadata": {},
"invoice_settings": { "default_payment_method": null }, "address": null } ],
"has_more": false }
Without email the list is still cursor-paginated (limit, starting_after) and
accepts the same created window as charges.
Save a card#
Tokenize the card in-page (ev: tokens) and attach it to the customer. The card is
vaulted; the PAN never reaches you in the clear.
curl https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "payment_method": { "token": "ev:...", "cvv": "ev:...", "exp_month": 12, "exp_year": 2030 }, "set_default": true }'
{ "id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461", "object": "payment_method", "customer": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "type": "card",
"card": { "brand": "Visa", "last4": "4242" } }
The card must be nested under
payment_method. A top-levelcardkey is not read, and the request fails withparameter_invalid: payment_method is required.
This rail accepts ev: tokens only. A raw card number is rejected, and so is a wp:
token. Only the tokenization service holds the key that opens a wp: envelope, so a wp:
value stored here would be a blob the vault could never reopen, and every later charge off
that saved card would fail.
The ev:test_token_pan_XXXX labels from POST /v1/test/tokens work
here too. They expand at the gateway, the same way a first charge does. You can vault a
card from curl without a browser.
Two defaults that are silent rather than loud:
exp_month/exp_yeardefault to 12 / 2030 when omitted, instead of being rejected. The card charges fine today but will not expire when the real card does, so the buyer's renewals keep hitting a dead card. Send the real expiry, as numbers.set_defaultdefaults tofalse. The first card you attach does not automatically become the default, passset_default: trueif this card should be the one subscriptions and later charges bill.
The response carries only what the vault returns: id, brand, last4. There is no
exp_month, exp_year, default or created on it, keep your own record of the expiry
you sent if you need to display it.
List them with GET /v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods;
delete one with
DELETE /v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods/pm_bb90c8f9-683b-4e75-8772-da2374a25461
(or DELETE /v1/customers/payment_methods/pm_bb90c8f9-683b-4e75-8772-da2374a25461?customer=cus_4023ec41-ca5f-4153-859f-c30c960a599c).
The list is not paginated: it returns every saved card for the customer in one response,
and has_more is always false.
Charge a saved card#
For renewals and other merchant-initiated charges, call POST /v1/charges with the saved
card's id as payment_method.payment_method_id (pm_<uuid> or a bare UUID). Set
recurring: true for a subscription renewal:
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "amount": 2000, "currency": "usd",
"payment_method": { "payment_method_id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461" },
"recurring": true,
"customer": { "email": "jane@example.com" } }'
Omitting recurring or setting it to false makes a saved-card charge a subsequent
unscheduled merchant-initiated transaction (MIT); true makes it a subsequent recurring
MIT. Only booleans are accepted, and true is rejected for a fresh card, wallet, wp_token
or ACH with 400 parameter_invalid, param: "recurring". A new renewal needs a new
idempotency key. Reusing a key with a different recurring value is an
idempotency_error; omitted and false are equivalent.
When the cardholder is paying now with a card they saved (on your checkout, not a renewal),
send "initiated_by": "customer" so the charge goes out as customer-initiated on a stored
credential. Your 3-D Secure rules then apply to it as in the Payment Element: the charge may
answer requires_action (resubmit with three_ds.session_id after the buyer's challenge)
or, for a card with no encrypted copy, soft_decline_3ds (ask for the card again). The
default, "merchant", is the MIT above. See
Charges → Charge a saved card.
Saved-card MITs are never sent to 3-D Secure: nobody is there to complete a challenge, and
card networks exempt them. A customer flagged Require 3-D Secure, the 3DS risk band, a
fraud rule asking for 3DS, or the route's Attempt 3DS does not decline them; they are
charged without 3DS. A fraud rule that blocks still blocks them. When the same customer
pays in the Payment Element, or on your checkout with
"initiated_by": "customer", their saved card is authenticated
(3DS on a saved card).
Each charge on a saved card references the payment the card was saved from (the initial customer-initiated transaction), as the card networks expect; you do not pass anything for this. A card saved before that reference was kept, or added without a payment, has none.
Installments are not supported. Transaction reporting classifies MITs broadly as recurring; it does not distinguish unscheduled MITs from subscription renewals.
Two shapes that catch people out:
- A bare-string
payment_methodis a saved-card id, not a card token. Sending"payment_method": "pm_bb90c8f9-683b-4e75-8772-da2374a25461"is wrapped aspayment_method_idand charged against the vault. A card token (ev:/wp:v1:) does not belong in that string slot, put it oncard.number,card.wp_token, orpayment_method.token. The object form above (payment_method.payment_method_id) is the unambiguous way to charge a saved card. customeron a charge is an object, not a customer id. It carries buyer details,email,name,phone, and only the email is used, to tag the charge with the buyer. Sending"customer": "CUS-1042"is not an error; the field is simply ignored, and the charge records no buyer email at all. The customer id is not part of the charge body, what ties the charge to the saved card ispayment_method_id.
A saved-card id is scoped to the merchant account that vaulted it, so a card belonging to
another account cannot be charged with your key. Beyond that, the id is not cross-checked
against a customer you name in the body (the body has no customer id to check it
against) so if you track which card belongs to which buyer, enforce that pairing on your
side before you charge.
