Customers & saved cards

View as Markdown

Create customers and save their cards so you can charge a returning customer server-side without re-collecting card details.

POST   /v1/customers
GET    /v1/customers           GET /v1/customers/:id           POST /v1/customers/:id   (update)
POST   /v1/customers/:id/payment_methods
GET    /v1/customers/:id/payment_methods
DELETE /v1/customers/payment_methods/:pmId
GET    /v1/customers/:id/transactions
GET    /v1/customers/:id/state
GET    /v1/customers/external/:externalId/state

Scope: customers:read (reads), customers:write (writes, server-only, pk_ blocked). GET /v1/customers/:id/transactions requires payments:read. It returns charges, not customer data.

Listing one buyer's charges. GET /v1/charges takes no customer filter, so use GET /v1/customers/:id/transactions.

  • Same shape. It returns the same row shape as GET /v1/charges, so one parser serves both.

  • Charges only. You get sales and authorizations, including those synced from your gateway. Refund, void and capture rows never appear.

  • Paging. It is cursor-paginated with limit (1–100) and starting_after. Pass the previous page's next_cursor as starting_after.

  • Per-row state. Each row says what it has already given back:

    • amount_refunded counts refunds made through the API and in your gateway's portal alike;
    • refunded is true once that is the whole amount;
    • disputed is true while a chargeback or matched alert is open on it;
    • card names the card it was paid with (brand, bin, last4).

    That is exactly what you need to refund a customer's past charges, for example instead of sending a payout. One customer can have paid with several cards, and a refund goes back to the card of the charge it refunds, so refund only charges on the card(s) of the person you owe. See Refund a customer's past charges.

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

bash
curl "https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/state" \
  -H "x-api-key: sk_live_xxx"
json
{
  "object": "customer_state",
  "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "external_id": null,
  "email": "ada@example.com", "name": "Ada Lovelace",
  "active_subscriptions": [ { "id": "sub_...", "status": "active", "plan": "plan_gold" } ],
  "payment_methods": [ { "id": "pm_...", "card": { "brand": "visa", "last4": "4242" } } ]
}

This is the whole entitlement layer. Cache it against your own user id and refresh it when a customer or subscription webhook arrives. There is no subscription state machine to maintain. Address it by our id via /customers/{id}/state, and keep the cus_… id POST /v1/customers returned next to your own user id.

active_subscriptions is filtered for you, and two of those decisions are worth stating because they are the ones every hand-written filter gets wrong:

StatusIn the list?Why
active, trialing
past_dueDunning 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, suspendedBilling is suspended, has not started, or dunning gave up.

It fails closed. If we cannot reach billing you get an error, never a customer with an empty active_subscriptions. An empty list means "entitled to nothing", and returning that during an outage would make every integrator caching this revoke access for their whole customer base.

external_id cannot be set yet#

GET /v1/customers/external/{id}/state looks a customer up by external_id, your own id for them. No API call sets that field today: POST /v1/customers reads only email, name, phone and idempotency_key, and POST /v1/customers/:id does not take it either, so a customer you create carries "external_id": null. The by-external-id route therefore answers 404 resource_missing (param: "external_id") for any id you choose. Use /customers/{id}/state with our id.


Create a customer#

bash
curl https://api.worthypayments.com/v1/customers \
  -H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "name": "Jane Doe", "phone": "+15555550123" }'
json
{ "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "object": "customer",
  "email": "jane@example.com", "name": "Jane Doe", "phone": "+15555550123",
  "created": 1733600000, "livemode": false, "metadata": {},
  "address": null, "invoice_settings": { "default_payment_method": null } }

name is split into first/last server-side. Customers are unique per email (per merchant and mode): an email that already has a customer returns 409 invalid_request_error with code: "already_exists", and GET /v1/customers?email= (below) finds that customer. A retry with the same idempotency_key (in the body; this route reads no Idempotency-Key header) and the same email returns the customer the first call created instead. Every customer object carries created (unix seconds), livemode, address (null until set), and invoice_settings.default_payment_method (null until a card is attached as default). Those keys are always present.


Update a customer#

POST /v1/customers/:id is Stripe's update (not PATCH). Idempotency-Key is optional, omit it, or send an empty string, and the name still updates. A present header is honoured.

bash
curl https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c \
  -H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
  -d '{ "name": "Ada Byron", "address": { "line1": "1 Ada St", "city": "London", "country": "GB" } }'

Find a customer by email#

GET /v1/customers?email= is an exact match (case-insensitive, plus-addresses are distinct). An unknown email is an empty list, not a 404:

bash
curl "https://api.worthypayments.com/v1/customers?email=buyer@example.com" \
  -H "x-api-key: sk_test_xxx"
json
{ "object": "list", "data": [ { "id": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "object": "customer",
  "email": "buyer@example.com", "name": "Ada Lovelace", "phone": "+15551230000",
  "created": 1733600000, "livemode": false, "metadata": {},
  "invoice_settings": { "default_payment_method": null }, "address": null } ],
  "has_more": false }

Without email the list is still cursor-paginated (limit, starting_after) and accepts the same created window as charges.


Save a card#

Tokenize the card in-page (ev: tokens) and attach it to the customer. The card is vaulted; the PAN never reaches you in the clear.

bash
curl https://api.worthypayments.com/v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods \
  -H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
  -d '{ "payment_method": { "token": "ev:...", "cvv": "ev:...", "exp_month": 12, "exp_year": 2030 }, "set_default": true }'
json
{ "id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461", "object": "payment_method", "customer": "cus_4023ec41-ca5f-4153-859f-c30c960a599c", "type": "card",
  "card": { "brand": "Visa", "last4": "4242" } }

The card must be nested under payment_method. A top-level card key is not read, and the request fails with parameter_invalid: payment_method is required.

This rail accepts ev: tokens only. A raw card number is rejected, and so is a wp: token. Only the tokenization service holds the key that opens a wp: envelope, so a wp: value stored here would be a blob the vault could never reopen, and every later charge off that saved card would fail.

The ev:test_token_pan_XXXX labels from POST /v1/test/tokens work here too. They expand at the gateway, the same way a first charge does. You can vault a card from curl without a browser.

Two defaults that are silent rather than loud:

  • exp_month / exp_year default to 12 / 2030 when omitted, instead of being rejected. The card charges fine today but will not expire when the real card does, so the buyer's renewals keep hitting a dead card. Send the real expiry, as numbers.
  • set_default defaults to false. The first card you attach does not automatically become the default, pass set_default: true if this card should be the one subscriptions and later charges bill.

The response carries only what the vault returns: id, brand, last4. There is no exp_month, exp_year, default or created on it, keep your own record of the expiry you sent if you need to display it.

List them with GET /v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods; delete one with DELETE /v1/customers/cus_4023ec41-ca5f-4153-859f-c30c960a599c/payment_methods/pm_bb90c8f9-683b-4e75-8772-da2374a25461 (or DELETE /v1/customers/payment_methods/pm_bb90c8f9-683b-4e75-8772-da2374a25461?customer=cus_4023ec41-ca5f-4153-859f-c30c960a599c). The list is not paginated: it returns every saved card for the customer in one response, and has_more is always false.


Charge a saved card#

For renewals and other merchant-initiated charges, call POST /v1/charges with the saved card's id as payment_method.payment_method_id (pm_<uuid> or a bare UUID). Set recurring: true for a subscription renewal:

bash
curl https://api.worthypayments.com/v1/charges \
  -H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "amount": 2000, "currency": "usd",
        "payment_method": { "payment_method_id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461" },
        "recurring": true,
        "customer": { "email": "jane@example.com" } }'

Omitting recurring or setting it to false makes a saved-card charge a subsequent unscheduled merchant-initiated transaction (MIT); true makes it a subsequent recurring MIT. Only booleans are accepted, and true is rejected for a fresh card, wallet, wp_token or ACH with 400 parameter_invalid, param: "recurring". A new renewal needs a new idempotency key. Reusing a key with a different recurring value is an idempotency_error; omitted and false are equivalent.

When the cardholder is paying now with a card they saved (on your checkout, not a renewal), send "initiated_by": "customer" so the charge goes out as customer-initiated on a stored credential. Your 3-D Secure rules then apply to it as in the Payment Element: the charge may answer requires_action (resubmit with three_ds.session_id after the buyer's challenge) or, for a card with no encrypted copy, soft_decline_3ds (ask for the card again). The default, "merchant", is the MIT above. See Charges → Charge a saved card.

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_method is a saved-card id, not a card token. Sending "payment_method": "pm_bb90c8f9-683b-4e75-8772-da2374a25461" is wrapped as payment_method_id and charged against the vault. A card token (ev: / wp:v1:) does not belong in that string slot, put it on card.number, card.wp_token, or payment_method.token. The object form above (payment_method.payment_method_id) is the unambiguous way to charge a saved card.
  • customer on a charge is an object, not a customer id. It carries buyer details, email, name, phone, and only the email is used, to tag the charge with the buyer. Sending "customer": "CUS-1042" is not an error; the field is simply ignored, and the charge records no buyer email at all. The customer id is not part of the charge body, what ties the charge to the saved card is payment_method_id.

A saved-card id is scoped to the merchant account that vaulted it, so a card belonging to another account cannot be charged with your key. Beyond that, the id is not cross-checked against a customer you name in the body (the body has no customer id to check it against) so if you track which card belongs to which buyer, enforce that pairing on your side before you charge.