# Customers & saved cards

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](./06b-refund-past-charges.md).
- **Unknown customer.** An unknown customer id returns an empty list.

`GET /v1/customers` accepts a `created` window in Unix seconds. See
[Pagination](./13-pagination.md#filtering-by-creation-time).

---

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

| 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

```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`](./15-testing.md) 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](./03-charges.md#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](./08b-payment-element.md), or on your checkout with
`"initiated_by": "customer"`, their saved card is authenticated
([3DS on a saved card](./07-3ds.md#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.
