# Payment Element

The Payment Element is a payment form we serve in an iframe and you drop into your page.
It collects the card, runs 3-D Secure **when enabled** for the account, shows saved cards,
and calls you back when the money is captured. Apple Pay and Google Pay mount on the
**merchant page, directly above the card iframe**. Not inside it. They are off until `GET
/v1/wallet-config` reports them on. See [Wallets and 3-D Secure](#wallets-and-3-d-secure),
which is also where the CSP entries they need are listed.

Because the fields live in our iframe and your scripts cannot read them, your page
stays on the PCI **SAQ A** path (~30 requirements) rather than SAQ A-EP (~139). That
is the reason the element exists.

```html
<script src="https://app.worthypayments.com/sdk/v1/worthypayments.umd.js"></script>
```

Load the pinned `/sdk/v1/` bundle. The unversioned `/sdk/worthypayments.umd.js`
always serves the newest build, which turns every deploy of ours into an untested
change to your live checkout.

---

## What the element draws, and what stays yours

The element renders a complete payment form and nothing else:

| Inside the iframe (ours) | On your page (yours) |
|---|---|
| Card number, expiry, CVC, and the network's mark once the number identifies one | The amount the buyer is paying |
| Name on card | Order summary, line items, shipping |
| Billing email, full name, address, city, postal, state, country | Your header, branding and layout |
| The **Pay** button | Terms, trust marks, support links |
| 3-D Secure and saved cards | The success and failure screens |

**Apple Pay and Google Pay are the exception: they mount on YOUR page, not in the
frame.** The SDK inserts them itself, in a block directly above the card form, because a
wallet sheet has to be opened by the page the buyer is actually on — that is the origin
Apple Pay checks against your registered domains. You do not wire them up (they appear whenever the key has
wallets enabled, unless you pass `wallets: 'never'`), but the block is in your document,
so its width and the space around it follow your layout.

⚠️ **The total is not in the form.** The amount lives on the intent, and the element
never displays it, so a buyer who sees only the iframe is pressing **Pay** without a
figure in front of them. Render the total yourself, next to the element. This is the
same rule from the other side: the browser cannot *name* the price, and it does not
*show* it either.

The `appearance` option restyles what is inside the frame so it does not look pasted onto
your page. It cannot move fields or add copy. See [Styling](#styling).

While the frame loads, the element holds **empty space** the size of the form that is
coming — it does not draw a stand-in form. Reserving the height keeps your page from
jumping when the real form arrives; drawing a fake one would make that arrival look like
the content changing under the buyer.

## Before anything renders: allowlist your domain

The element refuses to render for an origin that is not on your key's allowed domains,
and a refusal looks like a **blank frame** rather than an error. It is the most common
first-run failure.

Set the domains on your publishable key when you create it in **Settings → Developers →
API keys** (**Allowed domains** field; required for publishable keys). List the exact
hostnames that will embed the element. Update the list on the key later without rolling
the secret. A new checkout origin does not need a new `pk_`. The parent page must be
served over **https**. Loopback HTTP is the TLS exception for a local development server:
`http://localhost`, `http://127.0.0.1`, and `http://[::1]`. Opening a downloaded HTML file
(`file://`) is not an origin we frame. List the hostname (`localhost`) on the key like any
other domain. The secret key stays on your server; only `pk_` belongs in the page.

**Checkout framed by another of your sites.** The browser checks every page above the
card form, not only the one that mounts it. If `shop.example.com` mounts the element and
is itself framed by `dash.example.com`, list **both** on the key. Chrome, Edge and Safari
report the whole chain; Firefox reports only the page directly above, so on Firefox one
level of framing is supported.

Local checkout with that key is allowed. The page identifies itself with the `Origin`
header; the SDK already sends it. Do **not** put `http://localhost` in a public URL query
(`GET /v1/element-frameable?origin=http://localhost:3000`, or `?parent=http://localhost…`
on the element host). The public edge answers HTML 403 before the API runs. That is not a
ban on local development. Probe with the header and no query:

```bash
curl https://api.worthypayments.com/v1/element-frameable \
  -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
  -H "x-api-key: pk_test_xxx" \
  -H "Origin: http://localhost:3000"
```

`200 {"allowed":true}` means the key will frame that origin. See [Embedded SDK (local
development](./08-embedded-sdk.md#local-development) and [Errors) loopback in the
URL](./10-errors.md#loopback-in-the-url).

Wildcards match a single label, so `*.shop.com` covers `www.shop.com` but not
`a.b.shop.com`, and it does **not** cover the apex: `*.shop.com` leaves `shop.com`
refused, so list both if buyers check out at both. The same list decides which origins
may charge with the key, which is why it is not a styling detail.

**Important:** An empty allowlist does not open the element. Framing fails closed: no domains
configured → blank iframe. That is the opposite of the charge API's origin guard, which
treats an empty list as "no origin lock" for S2S calls. Set the list before you embed.

---

## The flow has two steps

```
1. YOUR SERVER   POST /v1/payment_intents   (sk_)  ->  client_secret
2. BROWSER       wp.mountPaymentElement('#payment', { clientSecret, onSuccess })
```

Your server names the price. The browser gets a secret that addresses that decision,
not the decision itself, so a buyer editing your page cannot change what they pay.

If your checkout only creates the order when the buyer pays, swap the two steps: mount
first with `mode: 'payment'`, and create the intent in `createIntent` at submit. See
[Mount before the order exists](#mount-before-the-order-exists).

The same mount collects the **first invoice of a subscription**. `POST /v1/subscriptions`
with `payment_behavior=default_incomplete` (or without a saved card) returns `status:
"incomplete"` and `latest_invoice.payment_intent.client_secret`, pass that secret here. Do
not put `plan` or `interval` on the Element; the server already named the plan. See
[Subscriptions](./18b-subscriptions.md).

---

## 1. Create the intent (your server)

Requires secret key + scope **`payment_intents:create`** (server-only).
`Idempotency-Key` is required. The browser later confirms with scope `none`: the
returned `client_secret` is the capability, not a second write scope.

```bash
curl https://api.worthypayments.com/v1/payment_intents \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: ord_1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 4999,
    "currency": "usd",
    "description": "Order #1234",
    "reference": "ord_1234",
    "receipt_email": "buyer@example.com"
  }'
```

| Field / header | Required | Notes |
|---|:---:|---|
| `amount` | Yes | Minor units integer (`4999` = $49.99). Decimals rejected. |
| `currency` | No | Defaults to `usd` |
| `description` | No | Line label |
| `reference` | No | Your order id (echoed later) |
| `metadata` | No | Your own key/value pairs, same rules as on `POST /v1/charges` (50 keys, keys up to 40 characters without `[`/`]`, string values up to 500). Answered to your server only, and copied onto the charge the intent makes. See [Metadata and the other details](#metadata-and-the-other-details) |
| `customer` | No | An existing customer of this key's merchant and mode: the `cus_…` id `GET /v1/customers` returns, the bare uuid, or the `CUS-…` number. The intent answers with the `cus_…` form. Any other value is a 400 `resource_missing` with `param: customer` (`No such customer: '…'`), and the same key replays it |
| `receipt_email` | No | Receipt destination. Send this **or** `billing.email` on confirm, with neither, the confirm fails `failed_precondition` / `customer email is required` |
| `three_ds` | No | `{ "request": "any" \| "challenge" \| "challenge_mandated" }` runs 3-D Secure on the card the buyer enters, whatever your rules decide (EMV indicators 01 / 03 / 04); default `automatic` leaves it to your rules, which still win when they ask. The Element runs the challenge, on a saved card too; only a saved card with no encrypted copy (saved before 2026-10-18, or imported) is re-entered first ([3-D Secure on a saved card](#3-d-secure-on-a-saved-card)); a wallet or a card sealed on the `wp:` rail pays without it. Read back on every intent as `three_ds.request`. See [3-D Secure](./07-3ds.md#asking-for-3ds) |
| `Idempotency-Key` | Yes | Header or body `idempotency_key` (stable per order) |

```json
{
  "id": "pi_4b124c602f49458b92204957ee5b21b6",
  "object": "payment_intent",
  "amount": 4999,
  "currency": "usd",
  "status": "requires_payment_method",
  "client_secret": "pi_4b124c602f49458b92204957ee5b21b6_secret_rz2MzQr49QlZ3TW2UWq5cMI6xb9dd0wUMl7h"
}
```

Send only `client_secret` to the page. It appears on create only. Base URL:
`https://api.worthypayments.com/v1`.

---

## 2. Mount the element (browser)

Copy this HTML, replace the two placeholders, and serve it from an origin on the
publishable key's allowed domains. Pin the `/sdk/v1/` bundle. `onSuccess` is required.

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Checkout</title>
    <!--
      If you send a CSP, the element needs at least:
        frame-src   https://element.worthypayments.com
        script-src  https://app.worthypayments.com
        connect-src https://api.worthypayments.com
      Miss frame-src and the form never appears (onLoadError after 10 s). Wallet / 3DS / Kount
      extras: https://app.worthypayments.com/developers/08-embedded-sdk.md (Content-Security-Policy).
    -->
    <script src="https://app.worthypayments.com/sdk/v1/worthypayments.umd.js"></script>
  </head>
  <body>
    <div id="payment"></div>
    <script>
      // clientSecret comes from YOUR SERVER: POST /v1/payment_intents (sk_).
      const clientSecret = 'pi_…_secret_…';
      const wp = WorthyPayments('pk_live_xxx');

      const element = wp.mountPaymentElement('#payment', {
        clientSecret,
        onSuccess: (r) => {
          // required. r.paymentIntentId, r.status, r.transactionId,
          // r.charge?.processor_transaction_id (NMI transactionid)
          if (r.status === 'succeeded') {
            // show confirmation; fulfil on charge.succeeded
          } else {
            // 'processing' — do not fulfil; wait for the webhook
          }
        },
        onError: (e) => {
          // declines land here, not onSuccess; the element stays mounted
        },
        onLoadError: (e) => {
          // no form at all (allowed domains, CSP, network): offer a checkout link instead
        },
      });

      // element.update({ clientSecret?, appearance?, locale?, defaultValues? })
      //   — never an amount. Cart changes PATCH the intent on the server;
      //     the client secret does not change, so a mounted element needs nothing.
      // element.submit()   — drive Pay from your own button
      // element.destroy()  — permanent; mount a new element to show the form again
    </script>
  </body>
</html>
```

Card entry, field validation, saved cards and retries happen inside the iframe. Apple Pay
and Google Pay mount on the merchant page, directly above that iframe. 3-D Secure is
driven by the element; a challenge opens inside the iframe, which grows to fit it, and your
page is covered while it runs ([The 3-D Secure overlay](#the-3-d-secure-overlay)).

| Option | Required | Default | When you need it |
|---|:---:|---|---|
| `clientSecret` | Yes, unless `mode` | — | From `POST /payment_intents` |
| `mode`, `amount`, `currency`, `createIntent` | Instead of `clientSecret` | — | Mount before the intent exists; your server creates it when the buyer pays. See [Mount before the order exists](#mount-before-the-order-exists) |
| `onSuccess` | Yes | — | Fulfil UI only; money confirmation still via webhook |
| `onError` | No | silent | Declines / faults (`card_declined`, …). Element stays mounted. |
| `onLoadError` | No | silent | The form could not load at all (`element_load_failed`, after 10 s): usually the key's allowed domains or your CSP. Not a decline: fall back to a checkout link, never "card declined". |
| `customerSessionSecret` | No | — | Returning buyer saved cards (from `POST /customer_sessions`) |
| `fields.billingDetails` | No | `auto` | `'never'` or `{ email, name, address }` each `'auto'\|'never'` |
| `defaultValues.billingDetails` | No | — | Prefill, and values for hidden blocks (keeps AVS) |
| `fields.savePaymentMethod` | No | `never` | `'auto'` draws an untickable-by-default "save this card" box, except on an intent created with `setup_future_usage`, which saves the card anyway. See [Saving a card](#saving-a-card-for-later) |
| `appearance` | No | — | Theme variables inside the frame |
| `payButton` | No | — | `{ label: 'Start membership' }` for your own wording, or `{ display: 'never' }` to draw no button and call `element.submit()` from yours |
| `locale` | No | `en` | `es`, `es-MX`, `es-ES`, … |
| `onReady` / `onChange` / `onCancel` | No | — | Load state, field completeness, wallet sheet dismiss |
| `onEscape` | No | — | The buyer pressed Escape inside the frame; close your own modal. Not called while 3-D Secure runs |
| `wallets` | No | `auto` | `'never'` when your page already mounts Apple Pay / Google Pay |
| `walletButtons` | No | black, 48 px | `{ applePay: { theme, type }, googlePay: { theme, type }, height }` — the providers' own button presets. See [Embedded SDK](./08-embedded-sdk.md#wallet-buttons) |
| `recurring` | No | — | This payment starts a subscription: the Apple Pay sheet shows its terms, and a wallet payment asks to save its method. See [Subscriptions paid by wallet](#subscriptions-paid-by-wallet) |

Handle methods: `element.update({ clientSecret?, appearance?, locale?, defaultValues? })`
(never an amount on a `clientSecret` mount; `fields` is mount-only), `element.submit()`,
`element.destroy()`.

**When the cart changes, your server writes the new total to the intent** with [`PATCH
/v1/payment_intents/:id`](#when-the-cart-changes). The client secret does not change, so
the mounted element needs nothing from you.

Full types: [Embedded SDK](./08-embedded-sdk.md).

### Three ready-made forms

The one option that changes what the buyer sees is `fields.billingDetails`. These three
settings cover most checkouts. Each has a runnable page: open it, paste your own test
keys, pay with a test card, and read what came back on the charge.

| Form | The frame draws | Mount with | Try it |
|---|---|---|---|
| **1 · Card only** | Card number, expiry, CVC, name on card | `fields: { billingDetails: 'never' }` **and** `defaultValues.billingDetails` from your page | [/demos/element-card-only.html](/demos/element-card-only.html) |
| **2 · Full billing** | Card + email, name, street, city, postal code, state where the country has one, country from a list of all 249 regions | nothing: `'auto'` is the default | [/demos/element-full-billing.html](/demos/element-full-billing.html) |
| **3 · Smart address** | Card + email, name, and only the address lines the buyer's country uses | `fields: { billingDetails: { address: 'if_required' } }` | [/demos/element-smart-address.html](/demos/element-smart-address.html) |

**Card only** is for a page that already holds the buyer: an account checkout, a
subscription upgrade, a second purchase. The buyer sees no billing field, and the charge
still carries the address, because you pass it:

```js
wp.mountPaymentElement('#payment', {
  clientSecret,
  fields: { billingDetails: 'never' },
  defaultValues: {
    billingDetails: {
      email: 'ada@example.com',
      name: 'Ada Lovelace',
      address: { line1: '1 Main St', city: 'Austin', state: 'TX',
                 postal_code: '78701', country: 'US' },
    },
  },
  onSuccess: (r) => { /* r.charge.billing_details carries that address */ },
});
```

`onSuccess` hands the address back under `charge.billing_details`, which is how the demo
page shows that AVS had its input. Two rules come with this form. `email` is not optional
unless the intent carries `receipt_email`: a confirm with neither fails `customer email is
required`. And hiding the block **without** `defaultValues` is the case the warning below
this section is about: no address reaches the issuer. If the buyer changes after the
element is mounted, `element.update({ defaultValues })` takes the new values. You can
call it right before `element.submit()`, in the same tick, to hand over the billing form
on your own page at the moment of payment: the submit confirms the values the update
carried. `address.line2` rides on the charge with `line1`; the element draws no field for
it, and drops it when the buyer types a different street.

**Full billing** is the default and needs no option. The element reports `complete: true`
only once every drawn field is filled, and the country is a list, not a text box, so a
typo cannot reach the acquirer as an AVS country.

**Smart address** keeps street and postal code, which AVS checks, and drops the state or
province line in the countries whose addresses have none. Pick the United States, then
the United Kingdom, and watch the line go. It asks nothing of your page, unlike `'never'`.

`phone` is off in all three, because no card rail needs it. `{ phone: 'auto' }` inside
the object form draws it.

### What comes back

The iframe confirms with `POST /v1/payment_intents/:client_secret/confirm` (publishable
key). That HTTP body is a `payment_intent` (`id`, `object`, `status`, `latest_charge`,
`amount`, `currency`, `customer`, … plus a receipt `charge`). The SDK maps it to
`FrameSuccess` before `onSuccess`. The parent page does **not** receive the extra intent
fields.

```ts
interface FrameSuccess {
  paymentIntentId: string;
  status: 'succeeded' | 'processing';
  transactionId: string | null;
  charge?: FrameCharge;   // named subset of GET /v1/charges/{id}, not the whole object
  card_save?: {           // only when the buyer ticked "save this card"; see below
    status: 'pending' | 'saved' | 'skipped' | 'failed';
    payment_method: string | null;  // 'pm_...' when saved
    failure_code: string | null;
    failure_message: string | null;
  };
}
```

```json
{
  "paymentIntentId": "pi_79bb4da94cac40d383ee3d0403b5f8be",
  "status": "succeeded",
  "transactionId": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
  "charge": {
    "id": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
    "object": "charge",
    "amount": 1999,
    "currency": "usd",
    "status": "succeeded",
    "paid": true,
    "captured": true,
    "amount_captured": 1999,
    "amount_refunded": 0,
    "auth_code": "TESTOK",
    "processor_transaction_id": "TEST-2a7af918-7888-4d74-87c0-45c800ea7477",
    "payment_method_type": "card",
    "metadata": {},
    "created": 1788217437,
    "card": { "brand": "visa", "last4": "4242" }
  }
}
```

`charge` is present whenever it could be read back and omitted when it could not, enough
to render "Paid $49.99 with Visa ••4242" without a second round-trip.
`processor_transaction_id` is the gateway's own id (NMI `transactionid`).

It is a named subset. The merchant and acquirer facts on the server charge (`mid`,
`processor`, `processor_label`, `network_transaction_id`, `arn`, `rrn`, settlement,
and the AVS / CVV verdicts) stay behind your secret key: they answer a different
question than what the buyer just paid, and this payload lands in a page the buyer
can read. Read them on `GET /v1/charges/{id}` (`payments:read`) or the
`charge.succeeded` webhook. Empty columns are omitted, not sent as `""`.

`processor` is Admin → Acquirers → **Code** of the acquirer **Payment Workflow** chose for
this sale (`NMI`, `PAYARC`, `NUVEI`, `MAVERICK`, … as configured. Not a fixed string). All
of those processors share the merchant's one NMI security key; the NMI `processor_id` on
the sale is that row's **Connector code (`processor_id`)** / charge code. **Connector
codes** map raw gateway keys onto the same row. Neither is the public `processor` field.
See [Charges](./03-charges.md#which-acquirer-ran-it-and-what-the-gateway-said).

No instrument reference crosses the boundary: no PAN, no CVV, no token, no vault id, and
no `card.bin`. `card_save.payment_method` is not one either: it is a WorthyPayments `pm_…`
id, chargeable only with your secret key or that buyer's own customer session. `brand` and
`last4` are what the buyer typed and what their receipt shows.
Use them to display, not to decide, fulfilment still reads the charge on your server or
the `charge.succeeded` webhook.

Keep `fields.billingDetails` at `'auto'` unless you already collect that block on
your page. Then hide it (`'never'` or `{ address: 'never' }`) **and** pass the
known values in `defaultValues.billingDetails` so they still ride on the charge.

> **Hiding the address without `defaultValues` sends the charge with no AVS, and that is
> not a cosmetic loss.** AVS is the issuer's check of the billing address against the one
> on file. Submitting nothing means the issuer scores the authorization without it, which
> shows up as a **lower approval rate** and, in a dispute, as a **weaker position**. An
> unverified address is one of the first things a chargeback file is judged on.  The
> element cannot cover for you here. `'never'` says "I collect this myself", and the frame
> has no way to see what your page collected, so it cannot tell a merchant who passed the
> address in `defaultValues` from one who dropped it. The only safe reading of `'never'`
> without `defaultValues` is **"I accept charging without AVS"**.  If you already have the
> buyer's address, hide the block *and* pass it:
> ```js
> fields: { billingDetails: { address: 'never' } },
> defaultValues: {
>   billingDetails: {
>     address: { line1: '1 Main St', city: 'Austin', state: 'TX',
>                postal_code: '78701', country: 'US' },
>   },
> },
> ```

---

## 3. Handle the three outcomes

**`onSuccess` with `status: 'succeeded'`.** The money is captured. You can show a
confirmation, though fulfilment should still key off the webhook.

**`onSuccess` with `status: 'processing'`.** The outcome is not known yet: the card
network did not tell us in time whether the card was charged. Do not fulfil. Wait for
`charge.succeeded`, or retrieve the intent (`GET /v1/payment_intents/{client_secret}`)
every few seconds. A retrieve asks for the outcome and never charges; the intent moves
to `succeeded`, or to `requires_payment_method` with
`last_payment_error.decline_code: 'payment_not_completed'` when nothing was charged and
the buyer can pay again. While the buyer is still in a 3-D Secure challenge it reads
`requires_action`, never "not charged". Never confirm again to find out: a confirm is a payment
attempt, not a status read. `processing` is also the fallback for any state we do not
recognise, on purpose: reporting `succeeded` would tell you to ship goods that may not
be paid for, and reporting a decline would tell a buyer whose card *was* charged that
it was not.

A confirm that is still running after 55 seconds (or after the longer `timeout` you
configured) is settled the same way: the SDK stops
waiting, retrieves the intent (a read that never charges) and reports `succeeded` or
`processing` from it. Only when that read fails too does `onError` receive
`code: 'request_timeout'`, and the outcome is then unknown: check the intent or wait for
the webhook before letting the buyer pay again.

**`onError` with `code: 'card_declined'`.** A decline does not throw and does not
arrive on `onSuccess`. The element stays mounted and the intent stays alive, so the
buyer can try another card without you re-creating anything. `status` on `onSuccess`
is never `'requires_payment_method'`. The error's `type` is `'card_error'`, for the card
form and for Apple Pay / Google Pay alike.

The error carries `declineCode` when the decline has one (`insufficient_funds`,
`do_not_honor`, `duplicate_transaction`, `processing_error`, …): the same value as the
intent's `last_payment_error.decline_code`. `message` is the wording the element already
showed the buyer, in the element's `locale` (English and Spanish):

| The decline | What the buyer reads |
|---|---|
| `duplicate_transaction` (the gateway refused it as a repeat of a payment just made) | "This looks like a duplicate of a payment you just made." |
| `insufficient_funds` | "Your card was declined for insufficient funds. Try another card." |
| `incorrect_cvc` / `cvv_mismatch` | "Your card's security code is incorrect." |
| `incorrect_zip` | "Your card's postal code is incorrect." |
| `incorrect_address` | "Your card's billing address is incorrect." |
| `expired_card` | "Your card has expired. Try another card." |
| `incorrect_number` / `invalid_number` | "Your card number is incorrect." |
| `amount_too_large` / `amount_too_small` | "This business can't accept a payment of this amount. You have not been charged." |
| `currency_not_supported` | "This business can't accept payments in this currency. You have not been charged." |
| `merchant_unavailable` | "This business can't accept payments right now. You have not been charged." |
| `test_mode_live_card` | "Your card was declined. This payment is in test mode: use a test card." |
| `soft_decline_3ds` on a saved card (3-D Secure is needed; the card form opens, see [3-D Secure on a saved card](#3-d-secure-on-a-saved-card)) | "Your bank needs to verify this card. Enter the card details again to continue. You have not been charged." |
| `three_ds_failed` (the 3-D Secure session did not authenticate) | "Your bank could not verify this card. Try again or use another card." |
| `payment_not_completed`, `try_again_later`, `issuer_unavailable`, and any other refusal by the gateway before the card network saw it (`last_payment_error.network_status: 'not_sent_to_network'`) | "The payment could not be processed. Please try again." |
| everything else, an issuer `processing_error` and the fraud family (`fraudulent`, `lost_card`, `stolen_card`, `pickup_card`) included | "Your card was declined. Try another card." |

The postal code, address, amount and currency lines come from your own
[Payment Workflow rules](./10-errors.md#payment-workflow-rules): the payment was refused
before it reached the gateway, and nothing was charged.

```js
onError: (e) => {
  if (e.declineCode === 'duplicate_transaction') {
    // the buyer may already have paid: check your orders before letting them pay again
  }
},
```

Do not let a buyer retry a `duplicate_transaction` blindly: look the payment up
(`GET /v1/charges`, or your `charge.succeeded` webhook) first. Read a gateway refusal from
`last_payment_error.network_status`, never from the code: `processing_error` is also an
issuer answer. See [Gateway refusals](./10-errors.md#gateway-refusals).

### Fulfil on the webhook, not in the browser

| Event | Meaning |
|---|---|
| `charge.succeeded` | Money captured. Fulfil now. |
| `charge.failed` | It will not settle. |

Match an event to your order with `data.object.detail.orderReference`: it is the
`reference` you set when your server created the intent, echoed back. Set it. A decline
carries it too, while the browser never receives a `transactionId` for a decline, so the
charge id alone cannot tie a `charge.failed` to an order. The intent's `metadata` rides along
too, as `data.object.detail.metadata`. Without a `reference`, use the
payment-intent fallback in [Webhooks](./16-webhooks.md#matching-a-delivery-back-to-your-order).
Configure the endpoint in **Settings -> Developers -> Webhooks** and verify the signature.
See [Events](./19-events.md).

Do not fulfil from a browser poll. The buyer may close the tab the moment they pay, and a
charge that settles two seconds later would then never be fulfilled. A page may retrieve
a `processing` intent to show the buyer its outcome, but fulfilment keys off the webhook,
which arrives regardless of what the page is doing.

---

## When the cart changes

The buyer adds an item, picks a different shipping option, applies your own discount. Write the
new total to the intent from your server:

```bash
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_4b124c602f49458b92204957ee5b21b6 \
  -H "x-api-key: sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"amount": 7500, "description": "Order #1234"}'
```

Note the two things this route does differently from every other one on this page:

- **It takes the `pi_...` id, not the client secret.** The secret is in the buyer's page.
  If a re-price were addressed by it, a buyer could re-price their own order, which is
  precisely the decision the element exists to keep on your server. Reaching this route
  needs the server-only scope `payment_intents:create`, so a `pk_` cannot call it at all.
- **The client secret does not change.** There is nothing to hand the browser and no
  `element.update()` to call; a mounted element keeps working and the buyer pays the new amount.

Apple Pay and Google Pay buttons drawn before the change are covered too. A wallet sheet
shows the total it was drawn with, so a wallet payment whose sheet still showed the old
total is refused before any charge: `onError` receives `code: 'amount_changed'`
(`type: 'invalid_request_error'`), and the buttons redraw with the new total for the buyer
to tap again.

Only an unpaid intent moves. Once the buyer has submitted and a charge is in flight, the
call fails with `400 failed_precondition` rather than quietly doing nothing, so a late cart
edit can never disagree with what the card was actually charged. An expired or paid intent
answers the same.

Retrying is safe. The body names the amount the intent should **end up** at, not a delta, so
sending it twice lands on the same total. That is why no `Idempotency-Key` is required here,
unlike create.

> **Do not create a second intent for a changed cart.** It still works, but it leaves the first
> one to expire unpaid, and the browser then has to be handed a fresh `clientSecret` through
> `element.update({ clientSecret })`. Patching is one call and the page never learns the price.

### Metadata and the other details

`metadata` is your own key/value pairs on the intent: an order id, a cart id, whatever ties the
payment to your system. Set it on create, change it with the same `PATCH`, and change
`reference`, `customer` and `receipt_email` there too. Send only what changes:

```bash
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_4b124c602f49458b92204957ee5b21b6 \
  -H "x-api-key: sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"reference": "ord_1235", "metadata": {"order_id": "8812", "coupon": ""}}'
```

- **`metadata` is merged, as on Stripe.** A key you send is set, a key sent as `""` is
  removed (`coupon` above), and keys you do not send stay. To empty it, send each key with
  `""`; `metadata: null` is refused. The intent holds at most 50 keys.
- **`reference` and `receipt_email`** are replaced; `""` or `null` clears them.
- **`customer`** is checked like on create and replaces the intent's customer. It cannot be
  removed, and it **cannot change once a payment attempt on the intent started**, a declined
  one included (`400 failed_precondition`, `param: customer`): that attempt, and a card it
  saved, belong to the customer the intent had then. A card already saved never moves.
- **The same states as the amount:** an intent that is paid, being paid or expired refuses
  every change with `400 failed_precondition`.
- **Who sees it.** `metadata` comes back with `reference` on create, on `PATCH`, and on
  retrieve with a secret or restricted key. The publishable key in the buyer's page never
  receives either, so you can keep the buyer's details or your internal ids there.
- **The charge copies it.** When the buyer pays, the charge takes the intent's `metadata`
  once, as it is at that moment: `GET /v1/charges/{id}` carries it as `metadata`, the
  `charge.*` webhooks as `detail.metadata`, and `checkout.session.completed` as `metadata`.
  Editing the intent after a declined attempt reaches the next attempt's charge, never the
  declined one.

---

## Mount before the order exists

Some checkouts only know the order when the buyer presses "Place order" (WooCommerce, a cart
that is priced on submit). Mount the element first, without a client secret, and create the
intent on your server when the buyer pays. This is Stripe's
`elements({ mode: 'payment', amount, currency })`.

```js
const element = wp.mountPaymentElement('#payment', {
  mode: 'payment',
  amount: 1099,          // the total, smallest currency unit
  currency: 'usd',
  // Runs when the buyer pays: create the intent with your secret key, return its secret.
  createIntent: async () => {
    const res = await fetch('/checkout/create-intent', { method: 'POST' });
    if (!res.ok) throw new Error('Could not create the order');
    return (await res.json()).client_secret;
  },
  onSuccess: (r) => { /* as above */ },
  onError: (e) => { /* declines, and intent_creation_failed when createIntent threw */ },
});

// The cart changed before the buyer paid:
element.update({ amount: 2099 });
```

1. The form is complete and the buyer presses Pay (or you call `element.submit()`, or a
   wallet sheet is approved).
2. The element calls `createIntent`. Your server runs `POST /v1/payment_intents` with the
   order's real total and returns the `client_secret`.
3. The element confirms that intent (3-D Secure included) and calls `onSuccess` or
   `onError`, exactly as a `clientSecret` mount does.

What to know:

- **Your server still names the price.** `amount` is what the Apple Pay / Google Pay sheet
  shows; the charge is the amount on the intent you create. Create it with the same total.
  A wallet payment whose intent disagrees with the sheet is refused before any charge
  (`amount_changed`).
- **An incomplete form never calls `createIntent`**, so no unpaid intent is left behind.
  Each attempt calls it again; your server may hand back the same unpaid intent's secret
  for a retry after a decline.
- **If `createIntent` throws,** nothing is charged: `onError` receives
  `intent_creation_failed` with your error's message, and the element tells the buyer the
  payment could not be started and lets them try again.
- When the cart changes, call `element.update({ amount })` so the wallet buttons redraw.
  There is no intent to PATCH yet. `update({ clientSecret })` is refused on this mount.

Full reference: [Embedded SDK](./08-embedded-sdk.md#mounting-before-the-intent-exists).

---

## Saving a card for later

A saved card is the other half of the section below: something has to enrol the card
before there is anything to show a returning buyer, and before a subscription has a
stored method to bill (see [Customers & saved cards](./18-customers.md)).

Turn the offer on at mount:

```js
wp.mountPaymentElement('#payment', {
  clientSecret,
  fields: { savePaymentMethod: 'auto' },   // draws the checkbox, UNTICKED
  onSuccess: (r) => { /* .. */ },
});
```

The element then draws a **"Save this card for future payments"** checkbox under the
card fields. The buyer ticking it is the consent, and the card is enrolled only on that
tick. Default is `'never'`: no box, nothing saved.

Three things about this that are deliberate and not configurable:

- **The box is never pre-ticked, and there is no option to pre-tick it.** A pre-ticked
  box is not valid consent under GDPR, and the same reasoning holds for card-network
  stored-credential rules.
- **It only appears for a fresh card.** A buyer paying with a card they already saved
  sees no box, because there is nothing to enrol.
- **Nothing is saved without it.** Absent consent the card is stored nowhere: no payment
  method on your account, and no vault entry at the gateway either.

You are responsible for the terms that go with this. Before you enable it, state on your
own page what you will use the saved card for, when you will charge it, and how the buyer
cancels, and keep a record of their agreement. The checkbox collects consent for
*storage*; it is not a substitute for your terms.

Attach the payment to a customer so the card has somewhere to live: pass `customer` on
[`POST /v1/payment_intents`](#1-create-the-intent-your-server). It must be an existing
customer of the same merchant and mode as your key, or the create answers 400
`resource_missing`. That customer is the payment's and the card's owner, whatever email
the buyer types in the Element; the email is only where the receipt goes. Without
`customer`, the buyer's email decides: the payment and the card attach to the customer
with that email, created if none exists.

### Always saving the card (subscriptions)

When the card must be saved whatever the buyer does, for example the first payment of a
subscription you renew yourself, create the intent with `setup_future_usage` and a
`customer`:

```bash
curl https://api.worthypayments.com/v1/payment_intents \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: sub_42_first" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 2900, "currency": "usd",
        "customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
        "setup_future_usage": "off_session" }'
```

- The card is saved to that customer once the payment is approved. The element draws no
  "save this card" box for this intent, even with `fields.savePaymentMethod: 'auto'`, and
  the buyer's page cannot turn the save off.
- With a deferred mount (`mode: 'payment'`, the intent created in `createIntent`), pass
  `setupFutureUsage: 'off_session'` (or `'on_session'`) to `mountPaymentElement` as well, so
  the element draws no box before the intent exists, and create each intent with the same
  `setup_future_usage`. The intent is what saves the card; the option only shapes the form.
  On a `clientSecret` mount the option is refused (`setup_future_usage_with_client_secret`).
- `off_session`: you will charge the card without the buyer present (renewals).
  `on_session`: the buyer will pay with it again at your checkout. Both save the card the
  same way.
- This payment is the card's initial stored-credential transaction. Later
  merchant-initiated charges on the saved card (`recurring: true`, `initiated_by:
  "merchant"` on [`POST /v1/charges`](./03-charges.md)) refer to it.
- `customer` is required: without it the create is a 400 `parameter_missing` with
  `param: customer`.
- The result is `card_save`, read as below. A wallet payment (Apple Pay, tokenised Google
  Pay) cannot be saved: it succeeds and answers `skipped` / `card_not_saveable`. A saved
  card the buyer picks is already on file and carries no `card_save`.
- The box was the buyer's consent; without it, consent is your checkout's job. State on
  your page that the card is kept for later payments and how they are charged.

### Did the card get saved?

When the buyer ticked the box and the payment succeeded, `onSuccess` receives
`card_save`, copied from the confirm response (the same object
`GET /v1/payment_intents/{client_secret}` returns for that intent):

```js
onSuccess: (r) => {
  if (r.card_save?.status === 'saved') {
    showSavedNotice(r.card_save.payment_method); // 'pm_...'
  }
},
```

```json
{
  "paymentIntentId": "pi_79bb4da94cac40d383ee3d0403b5f8be",
  "status": "succeeded",
  "transactionId": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
  "card_save": {
    "status": "saved",
    "payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "failure_code": null,
    "failure_message": null
  }
}
```

| `card_save.status` | What it means |
|---|---|
| `saved` | The card is saved for the customer (newly, renewed, or it already was). `payment_method` is its `pm_…` id. |
| `pending` | The save may still be running, or its outcome is unknown. **It can stay pending.** |
| `skipped` | Not attempted. `failure_code`: `customer_missing` (no customer on the payment), `card_not_saveable` (not a fresh card, e.g. a wallet), `payment_not_approved`. |
| `failed` | Attempted and failed; the payment still succeeded. `failure_code` names the reason (for example `VAULT_ADD_REFUSED`), `failure_message` carries the gateway's words when it gave some. |

`card_save` is **omitted**, never null, when the box was not ticked, when the payment did
not succeed, and when the result could not be read back (then `charge` is omitted too).
Saving never changes the payment's outcome.

**Do not wait for a webhook to settle `pending`.** A save that completes after the confirm
answered sends `payment_method.attached` only for a newly saved card,
`payment_method.updated` when it renewed a card the customer already had (same card, new
expiry), and no event at all when that card was already saved with the same expiry. Re-reading
the intent does not move a `pending` either. To settle it, read the customer's cards on your
server with `GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`).

**Two forms of the same id.** `card_save.payment_method` is the `pm_…` form, which is what
your server uses: `POST /v1/charges` takes it as `payment_method.payment_method_id`, and it
matches `GET /v1/customers/{id}/payment_methods`. The customer-session list below
(`savedCards()`, `GET /v1/customer_sessions/payment_methods`) returns the same card by its bare
id, without the `pm_` prefix. A browser confirm with a saved card takes either form, with the
customer session of the buyer who owns the card.

### Saving a card without a payment (setup intents)

A $0 trial sign-up, "add a payment method" on an account page, and a new card for a
subscription all need a saved card and no charge. Your server opens a setup intent for an
existing customer:

```bash
curl https://api.worthypayments.com/v1/setup_intents \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: signup_42_card" \
  -H "Content-Type: application/json" \
  -d '{ "customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f", "usage": "off_session" }'
```

and mounts the element with its `client_secret` (`seti_…_secret_…`), exactly as for a payment:

```js
wp.mountPaymentElement('#card', {
  clientSecret: setupIntentClientSecret,
  onSuccess: (r) => {
    // r.setupIntentId, r.payment_method ('pm_...'); r.paymentIntentId is '' — nothing was paid
    saveOnMyServer(r.payment_method);
  },
  onError: (e) => { /* e.code: card_declined (with e.decline_code) or processing_error */ },
});
```

- The element draws the card fields (and the billing fields you ask for) with a **Save card**
  button. It shows no saved cards, no save box and no wallet buttons: a wallet's device token
  cannot be saved.
- The card is verified with a $0 authorization and saved to the customer in the same gateway
  request, as the card's initial stored-credential transaction. Charge it later with
  `POST /v1/charges` and `payment_method: { payment_method_id: 'pm_…' }`; a renewal is
  `recurring: true`, `initiated_by: "merchant"`.
- A declined card leaves the form open for another card. After five failed attempts the setup
  intent accepts no more: create a new one.
- 3-D Secure runs when you create the setup intent with `three_ds: { request: 'challenge' }`
  (or `any` / `challenge_mandated`); the element shows the bank's challenge itself.
- Confirm on your server from `setup_intent.succeeded` (it names the saved card) or by
  retrieving the intent, not from the browser callback alone. A failed attempt sends
  `setup_intent.setup_failed`.
- `usage` (`off_session`, the default, or `on_session`) says what you will do with the card;
  both save it the same way. As with a payment that saves a card, tell the buyer at your page
  that the card is kept for later payments.
- Each attempt meets the same fraud screening as a payment (your blocked customers and
  cards, card-testing detection, your velocity rules) before the card is verified. A refusal
  reads like a decline: `last_setup_error` `card_declined` with `decline_code` `fraudulent`,
  and it counts toward the five attempts. Which rule fired is never answered.

**Mount before the setup intent exists.** As with [`mode: 'payment'`](#mount-before-the-order-exists),
pass `mode: 'setup'` and a `createIntent` that creates the setup intent on your server when
the buyer presses Save card, and resolves its `client_secret`:

```js
wp.mountPaymentElement('#card', {
  mode: 'setup',
  createIntent: async () => {
    const r = await fetch('/my-server/setup-intents', { method: 'POST' });
    return (await r.json()).client_secret; // 'seti_..._secret_...'
  },
  onSuccess: (r) => saveOnMyServer(r.payment_method),
});
```

It takes no `amount`, `currency` or `setupFutureUsage` (`amount_with_setup_mode`,
`setup_future_usage_with_setup_mode`), and a payment intent's secret from `createIntent` is
refused (`invalid_client_secret`), so a Save card button can never charge.

**Cancel or list.** `POST /v1/setup_intents/{id}/cancel` (secret key, `customers:write`) ends
an open setup intent, for a buyer who left the form or a duplicate you created; pass
`cancellation_reason` (`abandoned` | `duplicate` | `requested_by_customer`) if you track it.
A canceled intent takes no card. A succeeded one cannot be canceled: its card is saved, so
detach the card instead. `GET /v1/setup_intents` (secret key, `customers:read`) lists your
setup intents newest first, `?customer=` for one customer's.

### Changing a subscription's card

A subscription's buyer who wants to pay with another card saves it with a setup intent for
the subscription's customer (above), then your server points the subscription at it:

```bash
curl -X PATCH https://api.worthypayments.com/v1/subscriptions/sub_9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d \
  -H "x-api-key: sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "default_payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e" }'
```

Nothing is charged now; the next renewal charges the new card. The card must be an active
saved card of the subscription's own customer (another customer's card, or one you detached,
is a 404). A canceled subscription is refused. A `past_due` subscription keeps its retry
schedule, and its next retry charges the new card.

---

## Saved cards

To show a returning buyer their cards, create a **customer session** on your server
(scope **`customer_sessions:create`**, secret key). It is a short-lived grant for **one**
customer. Publishable keys cannot list your customer book.

A configured environment returns `201` with `client_secret`. `400
customer_sessions_unavailable` means this environment has no session HMAC secret
(`CUSTOMER_SESSION_TOKEN_SECRET`): the endpoint exists and fails closed rather than
minting an unsigned grant. Do not treat the 400 as a missing route.

```bash
curl https://api.worthypayments.com/v1/customer_sessions \
  -H "x-api-key: sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8"}'
```

```json
{
  "object": "customer_session",
  "customer": "cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8",
  "client_secret": "cs_live_xxx",
  "expires_at": 1754160000
}
```

`customer` accepts the `cus_<uuid>` id or the bare uuid, and is answered as `cus_<uuid>`.
The session lasts 30 minutes. `expires_at` is a Unix timestamp, so mint it when the buyer
reaches checkout rather than when the page first loads.

The `client_secret` from **this** response is what you pass as
`customerSessionSecret`. It is not the payment intent's client secret, and the two
names trip people up.

```js
wp.mountPaymentElement('#payment', {
  clientSecret,                          // from /v1/payment_intents
  customerSessionSecret: 'cs_live_xxx',  // from /v1/customer_sessions
  onSuccess: (r) => { /* .. */ },
});
```

The element renders the picker itself. If you want your own chooser instead,
`savedCards()` returns the list:

```js
const cards = await wp.savedCards(customerSessionSecret);
// [{ id: '6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e', card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2029 } }]
```

You get brand, last4 and expiry, which is enough to render a chooser. Vault ids and
instrument tokens stay server-side. `id` is the bare payment-method id (no `pm_` prefix);
pass it unchanged when you confirm with the saved card.

Creating customers is in [Customers](./18-customers.md).

### 3-D Secure on a saved card

When a payment on a saved card needs 3DS — your intent's `three_ds.request` asked for
it, or one of your rules did (the route's **Attempt 3DS**, the 3DS risk band, a customer
marked Require 3DS, another fraud rule; see
[When does a step-up happen?](./07-3ds.md#when-does-a-step-up-happen)) — the element runs
the challenge on the saved card itself, as it does for a typed card. This works because a
card saved through the element keeps an encrypted copy of its number that only our
encryption provider can decrypt. The buyer does not type anything.

A card saved before 2026-10-18, or imported from your gateway vault, has no encrypted copy.
Its attempt is declined with `decline_code: soft_decline_3ds` and **nothing is charged**.
The element then handles it:

1. It hides the saved cards and opens the card form, with the message "Your bank needs to
   verify this card. Enter the card details again to continue. You have not been
   charged."
2. The buyer types the card again and presses **Pay**. That card runs the challenge like
   any card entered for this payment, and is charged when it authenticates. When it is
   the same card, its encrypted copy is kept on the saved card, so the buyer is not asked
   again next time.

`onError` fires once for the first attempt, with `code: card_declined`,
`decline_code: soft_decline_3ds` and that message. The element stays mounted and the
intent stays payable, as with any decline: do not unmount it or show a failure screen.
The "Save this card" box is not offered again, because the card is already saved.

The same decline on a card the buyer just typed is the issuer's own soft decline and is
shown as a normal decline.

---

## Styling

You name variables, we apply them inside the frame.

```js
wp.mountPaymentElement('#payment', {
  clientSecret,
  appearance: {
    variables: {
      colorPrimary: '#7C3AED',
      colorText: '#E8EAED',
      colorBackground: 'transparent',
      borderRadius: '12px',
      fontFamily: 'Inter, sans-serif',
    },
    rules: {
      '.Input': { boxShadow: 'none' },
      '.Input:focus': { borderColor: '#7C3AED' },
    },
  },
  onSuccess: (r) => { /* .. */ },
});
```

You can recolor, restyle, narrow, and scroll the form inside the frame. You cannot
lift it out of the iframe (`position`, `display`, `transform` and `backgroundImage`
are dropped). The iframe box is still the host `#payment`. Unknown variables are ignored.
Full list: [Embedded SDK](./08-embedded-sdk.md).

The **Pay button** is filled with the `colorPrimary` you name (`rules['.Button']`
`backgroundColor` overrides it); without one it keeps the default look. Change its
wording with `payButton: { label }`, or hide it with `payButton: { display: 'never' }`
and call `element.submit()` from your own button. Its states have their own selectors
(`.Button--disabled`, `.Button--loading`, `.Button--paid`), as do placeholders
(`.Input::placeholder`), the "save this card" checkbox (`.Checkbox`, `.CheckboxInput`,
`.CheckboxLabel`), the card brand mark (`.CardBrand`) and a saved card under the pointer
(`.SavedItem:hover`). For **fonts**, the frame serves Inter,
Roboto, Open Sans, Lato, Montserrat and Poppins itself, so `fontFamily: 'Inter, sans-serif'`
renders in Inter; a font only your page loads cannot reach the frame and falls back down
your stack.

---

## Wallets and 3-D Secure

Apple Pay and Google Pay mount on the **merchant page, directly above the card iframe**.
Not inside it. Apple's domain association and the loopback exemption are checked against
the origin the sheet runs on, so a wallet mounted inside our frame would be checked against
*our* origin instead of your registered domain. The PAN stays in the iframe; a wallet
carries a cryptogram.

Google Pay has one shape that does not: `PAN_ONLY`, a card saved in the buyer's Google
Account rather than tokenised on an Android device (the usual case in desktop browsers and
on iOS). It returns the card itself with no cryptogram. It is charged as an ordinary
card rather than as a wallet — so it is scored like one and can be stepped up to 3DS,
which a wallet cannot. Nothing changes on your side; the button simply shows for those
buyers too, where it used to be hidden from them entirely.

There is no separate mount call. The sheet total is loaded from the intent. `onCancel`
fires when the buyer dismisses a sheet. Pass `wallets: 'never'` if you already mount
your own. The buttons' theme, wording and height are `walletButtons` (Apple and Google
draw them, so `appearance` does not reach them).

A wallet payment answers on the same callbacks, in the same shapes, as the card form:
`onSuccess` receives the same result (receipt fields and `card_save` included), and a
decline reaches `onError` as `card_declined` with `declineCode` and the same buyer
sentence. The sheet stays open until the charge answers; on a decline it is failed with
that same sentence.

The sheet asks the buyer for an email, name, phone and billing address, and the element
sends what the wallet returned with the payment, so a wallet buyer gets a receipt and an
address for AVS. Values you pass in `defaultValues.billingDetails` win over the wallet's.

### Subscriptions paid by wallet

When the payment starts a subscription, say so with `recurring`:

```js
wp.mountPaymentElement('#payment', {
  clientSecret,                 // the intent names a `customer`
  recurring: {
    description: 'Pro membership',
    billingAgreement: 'You will be charged $9.99 every month until you cancel.',
    managementUrl: 'https://shop.example/account/subscriptions',
    regularBilling: { label: 'Pro', amount: 999, intervalUnit: 'month' },
    // trialBilling: { label: 'First week free', amount: 0 },
  },
  onSuccess: (r) => { /* r.card_save says whether a renewal method was kept */ },
});
```

| Field | Required | Default | Meaning |
|---|---|---|---|
| `description` | Yes | — | What the buyer subscribes to, shown on the Apple Pay sheet |
| `billingAgreement` | Yes | — | The terms shown on the sheet before the buyer authorizes |
| `managementUrl` | Yes | — | An `https` page where the buyer manages or cancels |
| `regularBilling.label` / `.amount` | Yes | — | The repeating charge; `amount` in minor units, above zero |
| `regularBilling.intervalUnit` | No | `month` | `day`, `week`, `month`, `year` |
| `regularBilling.intervalCount` | No | `1` | Every N units |
| `regularBilling.startDate` | No | now | When the repeating charge starts (ISO-8601 or `Date`) |
| `trialBilling` | No | — | `{ label, amount, startDate? }`, an introductory period; `amount` may be 0 |

It does two things, both on the wallet buttons only:

- **The Apple Pay sheet opens as a recurring payment request.** The buyer sees the
  description, the agreement, the cadence and the link, and Apple can issue a token that
  belongs to your relationship with the buyer rather than to their phone.
- **A wallet payment asks to save its payment method**, as a ticked "save this card" does
  on the card form, and `onSuccess` receives [`card_save`](#did-the-card-get-saved). The
  subscription is the consent, so state its terms on your page. The intent needs a
  `customer`, or `card_save` is `skipped` / `customer_missing`.
- **Google Pay offers only the cards it can hand back as the card itself** (`PAN_ONLY`).
  A device token cannot be saved, so a subscription paid with one would end at its first
  payment.

What `card_save` says today, by wallet:

| The buyer paid with | `card_save` | For renewals |
|---|---|---|
| Google Pay (with `recurring`, always the card itself, `PAN_ONLY`) | `saved`, with a `pm_…` | Charge that `pm_…` like any saved card |
| Apple Pay | `skipped` / `card_not_saveable` | Collect a card for renewals |

An Apple Pay token cannot be saved yet: the gateway has to accept wallet tokens into its
vault first. To keep Apple Pay off a subscription, use the express element with
`paymentMethods: { applePay: 'never' }` ([`mountExpressCheckout`](./08-embedded-sdk.md#mountexpresscheckouttarget-opts)). The sheet still shows the subscription terms. The amount the buyer
authorizes now is still the intent's total; `regularBilling` describes what follows.
A malformed `recurring` (a missing field, an `http` link, a fractional amount) reaches
`onError` and the Apple Pay button is not drawn. Mount-only. The card form is unchanged:
it keeps following `fields.savePaymentMethod`.

### Wallet config may be none

Do not assume Apple Pay or Google Pay are on. The SDK reads `GET /v1/wallet-config` (scope
`none`). Both flags false is the default, not an error, no buttons mount:

```json
{
  "object": "wallet_config",
  "mode": "test",
  "provider": "NONE",
  "applePay": false,
  "googlePay": false,
  "evervaultMerchantId": ""
}
```

Mount a button only on a literal `true`. Treat the flags as fail-closed.

**Dashboard (in-repo today).** Merchant Dashboard → **Settings → Checkout Settings →
Digital Wallets**. Switch Apple Pay / Google Pay on and save.

- **Test mode:** provision a sandbox wallet with **Create test wallet** on the same
  page, then flip the switches. Google Pay needs only that record; Apple Pay also
  needs a registered domain.
- **Live mode:** the switches stay disabled until support provisions a live wallet
  merchant record. Contact support; there is no self-serve live provisioner.

Until `wallet-config` reports a wallet on, the element is card-only. That is expected.

**CSP only when a wallet is actually on** (scripts load on your page, not in the iframe):

```
script-src  https://applepay.cdn-apple.com https://pay.google.com
frame-src   https://pay.google.com
```

Miss them and the buttons silently do not render. Safari blocks the Apple script without a
console error, check CSP before anything else.

### 3-D Secure and `return_url`

When a charge is stepped up, the element drives 3-D Secure itself: it mounts the challenge
once, inside the element's own frame (the frame grows to fit it), then resubmits
automatically. If the issuer authenticates without a challenge, no challenge is shown; the
[overlay](#the-3-d-secure-overlay) says the card is being verified until the outcome arrives.
There is nothing to wire. Driving 3DS yourself (native app or server-driven flow) is
[3-D Secure](./07-3ds.md).

Paygeyt has no `return_url` field on PaymentIntents or on `mountPaymentElement`. Stripe
uses `return_url` for a full-page redirect after 3DS; here the element stays on the
checkout page and the required `onSuccess` callback is the return path. Do not send
`return_url`.

### The 3-D Secure overlay

While 3-D Secure runs for a confirm from the card form, a payment or a card saved with a
setup intent (the bank collecting device data, a frictionless authentication, a challenge,
and the confirm after it), the element
covers your whole page: a dimmed layer with a spinner and "Verifying your card…"
("Verificando tu tarjeta…" for a Spanish `locale`). It is on by default and has no option,
as Stripe draws its own 3-D Secure UI over the merchant's page. A payment with no 3-D Secure
shows no overlay.

- **The challenge sits above it.** The bank's screen opens inside the element's frame; the
  overlay leaves the frame's area uncovered while it is open, and scrolls the frame into view
  if it is off screen. Nothing of yours is lifted or restacked.
- **Your page cannot be used underneath.** Clicks are blocked and focus is kept on the overlay
  (on the frame while the challenge is open), so your own "Place order" button cannot be
  pressed twice. `element.submit()` during a payment is ignored, as it always was.
- **Escape dismisses nothing**, the same as inside the bank's screen; the challenge's own
  control is the way out. The key does not reach your page's handlers while the overlay is up,
  and `onEscape` is not called, so a dialog of yours that closes on Escape stays open.
- **It comes down with the outcome**: success, decline, error, a failed or abandoned
  challenge. It is gone before `onSuccess` or `onError` runs, so whatever you show there is not
  drawn under it, and focus goes back where it was. `element.destroy()` removes it too.
- **Nothing moves.** It is a fixed layer, the page is not scroll-locked and nothing is
  injected into your stylesheet, so it needs no CSP change.
- **It follows `appearance`:** the card's background, text colour, font and corner radius,
  and the spinner's `colorPrimary`. The dimmed layer is the same on every page. The spinner
  stands still for a buyer who asked for reduced motion.

If your page shows its own busy state while a payment runs, keep it: the overlay is drawn
above everything else on the page (`z-index: 2147483647`).

---

## Errors worth recognising

| Code | Cause |
|---|---|
| `card_declined` | Arrives on `onError` (`type: 'card_error'`), with `declineCode` when the decline has one. The element stays mounted; let the buyer retry, except after `duplicate_transaction`. |
| `three_ds_failed` / `three_ds_cancelled` | Arrives on `onError` (`type: 'card_error'`): 3-D Secure did not authenticate the card, or the buyer closed the challenge. Nothing was charged and the element stays mounted. `message` is the sentence the buyer read, in the element's `locale`: "Your bank could not verify this card. Try again or use another card." or "Card verification was cancelled. You have not been charged." |
| `amount_changed` | Apple Pay / Google Pay only: the intent's total changed after the sheet was drawn. Nothing was charged; the buttons redraw with the new total. |
| `request_timeout` | The confirm and the read after it both failed to answer. The outcome is unknown: check the intent before letting the buyer pay again. |
| `element_load_failed` | On `onLoadError`, not `onError`: the form never loaded. Check the key's allowed domains and your CSP. |
| `parameter_unknown` | An `amount` or `currency` reached confirm. The intent fixed those. |
| `secret_key_in_browser` | An `sk_` reached the browser. Use `pk_` plus a client secret. |
| `resource_missing` | No such intent for this key's merchant and mode. |
| `container_not_found` | The selector you passed matched no element. |

The full table is in [Errors](./10-errors.md); the tokenization codes are in
[Error codes](./30-error-codes.md).

Test cards and how to force a decline or a 3DS challenge are in
[Testing](./15-testing.md).
