# Embedded SDK (`worthypayments.js`)

Reference for the browser SDK. The client publishes seven methods, and one of them is the
integration: `mountPaymentElement()` renders a payment form inside an iframe we serve, so
the card never enters your page. `mountExpressCheckout()` is its wallet-only counterpart:
Apple Pay and Google Pay buttons with no card form, for a product page or a cart.

`Object.keys()` returns fifteen. The extras are frame internals, not features. See [what
the client
publishes](./27-integration-hard-rules.md#15-the-browser-integration-is-the-payment-element)
before you call one.

For a walkthrough of taking a first payment, read
[Payment Element](./08b-payment-element.md). This page is the specification.

---

## Loading the bundle

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

That URL is pinned to a version. There is also an unversioned
`https://app.worthypayments.com/sdk/worthypayments.umd.js`, which always serves the newest
build. Do not point a live checkout at it: every deploy we make becomes an untested change
to your payment page. It exists for our own demos.

The bundle defines exactly one global, `window.WorthyPayments`, and it is the factory
function itself.

An npm package (`@worthypayments/js`) exists in the repository but is **not published
yet**. Until it is, the script tag is the supported install path.

---

## `WorthyPayments(publishableKey, options?)`

```js
const wp = WorthyPayments('pk_live_xxx', {
  apiBase: 'https://api.worthypayments.com/v1',
  elementOrigin: 'https://element.worthypayments.com',
  timeout: 15000,
  debug: false,
});
```

Pass a publishable key. The factory throws before a client exists if you pass anything
else.

| Key prefix | Result |
|---|---|
| `pk_` | Accepted |
| `sk_` | Throws `secret_key_in_browser` |
| Anything else | Throws `invalid_key` |
| Missing or not a string | Throws `missing_key` |

### `WorthyPaymentsOptions`

| Option | Type | Default | Behaviour |
|---|---|---|---|
| `apiBase` | `string` | `https://api.worthypayments.com/v1` when the SDK was loaded from a `worthypayments.com/sdk/` URL (as in these guides); `https://api.paygeyt.com/v1` otherwise | Where the SDK sends API calls. Must be https and must be a WorthyPayments host (`paygeyt.com` or `worthypayments.com`, or a subdomain). `localhost` over http is exempt for development. Anything else throws at construction. |
| `elementOrigin` | `string` | `https://element.worthypayments.com` when the SDK was loaded from a `worthypayments.com/sdk/` URL (as in these guides); `https://element.paygeyt.com` otherwise | Where the hosted card frame is served from. For staging. Must be https and must **not** be your own origin. The card fields are sealed off by the same-origin policy, so a same-origin frame throws `same_origin_frame`. `http://localhost` and `http://127.0.0.1` are exempt for development; **`http://[::1]` is not** (unlike `apiBase`, which does accept it). |
| `timeout` | `number` | `15000` | Per-request timeout in milliseconds. A payment confirm waits at least 55 s, or this value if it is longer, because the charge behind it can outlast a read. |
| `debug` | `boolean` | `false` | Verbose console logging. |

`apiBase` is not a routing preference. It decides where the SDK fetches the public key
that card data is sealed to, so it is the destination of card data, and the host check is
a theft control rather than hygiene.

`elementOrigin` rejects your own origin with `same_origin_frame`. The PCI reduction rests
on the card fields living in a document your scripts cannot reach into, and same-origin
means they can.

The API base is `https://api.worthypayments.com/v1`.

### Local development

Point both options at your local stack. **They must be different origins**. The element is
a cross-origin frame by design, so serving your page and the frame from one port makes
`mountPaymentElement` throw `same_origin_frame`.

```js
const wp = WorthyPayments('pk_test_…', {
  apiBase: 'http://localhost:8101/v1',      // public API
  elementOrigin: 'http://127.0.0.1:3005',   // the frame — note the different host
});
```

Serving your own page from `http://localhost:5173` (or any port that is not the frame's)
satisfies the cross-origin rule. `localhost` and `127.0.0.1` count as different origins to
the browser, which is why the pair above works even though both are loopback.

Five things that trip people up locally:

- **Allowlist the dev host on the key.** `localhost` and `127.0.0.1` are ordinary entries
  and must be listed when the key is minted, or the frame renders blank
  (`frame-ancestors 'none'`). See [Quickstart step 2](./01-quickstart.md#2-allowlist-your-domains).
- **Do not put `http://localhost` in a public URL query.** Local checkout with a test key
  is allowed. The browser tells the API who it is with the `Origin` header. The SDK
  already does this. A query string of `?origin=http://localhost…` on
  `/v1/element-frameable` (or `?parent=http://localhost…` on the element host) is HTML 403
  at the public edge and never reaches the API. That is not "local development is
  forbidden". Prove the allowlist with the header, 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 {"object":"element_frameable","allowed":true}` when `localhost` is on the key. See
  [Errors, loopback in the URL](./10-errors.md#loopback-in-the-url).
- **`http://[::1]` is refused for `elementOrigin` only.** It is fine as your *page's*
  origin: the allowlist check treats `localhost`, `127.0.0.1` and `[::1]` alike. The
  restriction is on the option naming where the frame is *served from*, `apiBase` accepts
  `[::1]`, `elementOrigin` does not. Use `127.0.0.1` there. Three different loopback rules
  live in three different layers, and this is the only one that excludes `[::1]`.
- **A stale cached bundle looks exactly like a broken integration.** `/sdk/v1/` is served
  with `max-age=14400`, so a page that loaded the SDK before a fix shipped keeps the old
  one for up to four hours. The symptom is the worst kind: the iframe is there, the space it
  reserved never fills with a form, `onError` does not fire and the console is clean. Hard-reload before you
  debug anything else.
- **Your server still creates the intent.** The secret key does not become browser-safe
  because the host is localhost (see [hard rule 6](./27-integration-hard-rules.md).)

---

## The client

```ts
interface WorthyPaymentsClient {
  mountPaymentElement(target: string | HTMLElement, opts: PaymentElementOptions): PaymentElementHandle;
  mountExpressCheckout(target: string | HTMLElement, opts: ExpressCheckoutOptions): ExpressCheckoutHandle;
  savedCards(customerSessionSecret: string): Promise<SavedCard[]>;
  ping(): Promise<PingResult>;
  getFingerprint(): Promise<string | null>;
  getKountSessionId(): Promise<string | null>;
  getDeviceSession(): Promise<string | null>;
}
```

That is the whole public browser surface.

---

## `mountPaymentElement(target, opts)`

Renders the payment form into `target` and returns a handle. Synchronous: it creates the
iframe and returns immediately, then the frame reports readiness through `onReady`.

`target` is a CSS selector or an element. If nothing matches, it throws
`container_not_found`.

The iframe is created with `allow="payment"`, `width: 100%`, and a starting height of 80px
that the frame then adjusts. Height is clamped to between **80px and 2000px**, because a
frame that could name its own height could paint over the host page. Apple Pay and Google
Pay do **not** run inside this iframe. They mount on your page above it. See
[Wallets](#wallets).

### `PaymentElementOptions`

| Option | Type | Required | Default | Behaviour |
|---|---|---|---|---|
| `clientSecret` | `string` | Yes, unless `mode` | — | From `POST /v1/payment_intents` on your server. Addresses the intent that holds the amount. See [Creating the intent](#creating-the-intent). |
| `mode` | `'payment'` | Instead of `clientSecret` | — | Mount before the intent exists; your server creates it at submit. Requires `amount`, `currency` and `createIntent`. See [Mounting before the intent exists](#mounting-before-the-intent-exists). |
| `amount` | `integer` | With `mode` | — | Smallest currency unit, positive (`1099` is $10.99). What the Apple Pay / Google Pay sheet shows. The amount charged is the one on the intent `createIntent` returns. |
| `currency` | `string` | With `mode` | — | ISO 4217, three letters (`'usd'`). |
| `createIntent` | `() => Promise<string>` | With `mode` | — | Creates the payment intent on your server and resolves its `client_secret`. Runs once per payment attempt, after the form is complete. |
| `customerSessionSecret` | `string` | No | — | Shows that one buyer's saved cards inside the element. See [Customer sessions](#customer-sessions) for where this value comes from. |
| `appearance` | `Appearance` | No | — | Theme, variables and rules. See [Appearance](#appearance). |
| `locale` | `string` | No | `'en'` | UI language inside the frame. |
| `fields.billingDetails` | `'auto' \| 'never' \| { email?, name?, phone?, address? }` | No | `'auto'` | Which billing blocks the element draws. See below. |
| `defaultValues.billingDetails` | `{ email?, name?, phone?, address? }` | No | — | Prefill, and the values used for any block set to `'never'`. |
| `payButton` | `{ label?: string; display?: 'auto' \| 'never' }` | No | `{ display: 'auto' }` | `label` replaces "Pay" on the idle button (1-40 characters of plain text; anything else keeps "Pay"). `display: 'never'` draws no button: call `submit()` from your own. Mount-only. Its colour follows `appearance.variables.colorPrimary` (see [Appearance](#appearance)). |
| `onReady` | `() => void` | No | — | The frame has loaded and been initialised. |
| `onChange` | `(state: { complete: boolean; empty: boolean }) => void` | No | — | Field state changed. Use it to enable your own submit button. `complete` covers the card **and every billing field the element is drawing** — see below. |
| `wallets` | `'never'` | No | — | Suppresses Apple/Google Pay entirely. They mount on YOUR page above the card form, not in the frame, and their corner radius follows `appearance.variables.borderRadius`. |
| `walletButtons` | `{ applePay?: { theme?, type? }; googlePay?: { theme?, type? }; height?: number }` | No | black, provider's wording, 48 | How the Apple Pay and Google Pay buttons look. See [Wallet buttons](#wallet-buttons). Mount-only. |
| `recurring` | `WalletRecurring` | No | — | This payment starts a subscription. See [Subscriptions on the wallet buttons](#subscriptions-on-the-wallet-buttons). Mount-only. |
| `onSuccess` | `(result: FrameSuccess) => void` | **Yes** | — | The only required callback. |
| `onError` | `(err: WorthyPaymentsError) => void` | No | — | Declines and faults. Without it, errors are silent to your page. |
| `onLoadError` | `(err: WorthyPaymentsError) => void` | No | — | The form did not load at all, 10 s after mount (`code: 'element_load_failed'`). The usual cause is the key's allowed domains not covering this page; a CSP without `frame-src` is the next. Not a payment failure: offer a hosted checkout link, never "card declined". |
| `onCancel` | `() => void` | No | — | The buyer dismissed a wallet sheet. The card form has no cancel. |
| `onEscape` | `() => void` | No | — | The buyer pressed Escape inside the frame. Use it to close your own modal (see below) |

Note what is absent from a `clientSecret` mount: there is no `amount` and no `currency`.
The price lives on the intent your server created. A buyer editing your page cannot change
what they pay.

### Mounting before the intent exists

Pass `mode: 'payment'` with `amount`, `currency` and `createIntent` instead of
`clientSecret` when your page only creates the order at submit (Stripe's
`elements({ mode: 'payment', amount, currency })`):

```js
const element = wp.mountPaymentElement('#payment', {
  mode: 'payment',
  amount: 1099,
  currency: 'usd',
  createIntent: async () => {
    const res = await fetch('/create-intent', { method: 'POST' });
    const { client_secret } = await res.json();
    return client_secret;
  },
  onSuccess: (result) => { /* show your confirmation */ },
  onError: (err) => { /* err.code, err.message */ },
});
```

`createIntent` runs when the buyer presses Pay, when you call `element.submit()`, and when an
Apple Pay or Google Pay sheet is approved. It only runs once the form is complete, and once
per attempt: a retry after a decline calls it again. Your server may answer a retry with
the same unpaid intent's secret (an intent stays usable after a decline) or a new one.

Coming from Stripe: `createIntent` takes the place of `elements.submit()`, your fetch, and
`stripe.confirmPayment({ clientSecret })`. The element confirms by itself, with its own Pay
button and its own wallet buttons, so all three ways a payment starts go through this one
callback.

- **The price is still your server's.** `amount` is what the wallet sheet shows; the payment
  charges the amount on the intent `createIntent` returns. A wallet payment whose intent has
  a different amount or currency than the sheet is refused before any charge
  (`amount_changed`). The card form shows no amount and checks none.
- **A failed `createIntent` charges nothing.** A rejection reaches `onError` as
  `intent_creation_failed`, with your error's message; a value that is not a client secret as
  `invalid_client_secret`. The element shows "The payment could not be started" and the
  buyer can try again.
- **The amount never enters the frame.** It stays on your page for the wallet sheet.
- When the cart changes, call `element.update({ amount })` (see
  [`PaymentElementHandle`](#paymentelementhandle)) so the wallet buttons redraw with the new
  total, and create the intent with that total.
- Everything before the intent exists is keyed by your publishable key alone: card
  encryption, device data, wallet availability and saved cards (`customerSessionSecret`)
  all work at mount. Only confirm and 3-D Secure use the intent.

Mount errors, thrown as `invalid_request_error`: `client_secret_with_mode` (both given),
`invalid_mode` (anything but `'payment'`), `invalid_amount`, `invalid_currency`,
`missing_create_intent`. With neither `mode` nor a valid `clientSecret` the mount throws
`invalid_client_secret`, as before.

### Creating the intent

`clientSecret` comes from `POST /v1/payment_intents` on your server. That route requires
the server-only scope **`payment_intents:create`** (a publishable key is refused; minting
an intent is deciding the price). Send `Idempotency-Key` (or body `idempotency_key`);
omitting both returns `idempotency_key_required` before an intent exists.

| Field | Type | Required | Notes |
|---|---|---|---|
| `amount` | `integer` | Yes | Smallest currency unit. `4999` is $49.99. A decimal is rejected rather than rounded, because `49.99` would otherwise charge 49 cents. |
| `currency` | `string` | No | ISO 4217. Defaults to `usd`. |
| `description` | `string` | No | Shown on the charge. |
| `customer` | `string` | No | An existing customer of this key's merchant and mode: `cus_<uuid>`, the bare uuid, or the `CUS-…` number. The payment and a saved card belong to this customer, whatever email the buyer types; without it, the buyer's email decides. Any other value is a 400 `resource_missing` with `param: customer`. |
| `receipt_email` | `string` | No\* | Where the receipt is sent. \*Optional on its own, but the pair is not: with neither this nor `billing.email` on confirm, the confirm fails `failed_precondition` / `customer email is required`. |
| `reference` | `string` | No | Your own order or cart id. |
| `idempotency_key` | `string` | One of header or body | Same value as `Idempotency-Key`. Keep it stable across retries of the same payment. |

Send the `client_secret` from the response to your page. The `sk_` stays on your server.

### Confirm (what the element calls)

The element confirms with `POST /v1/payment_intents/:client_secret/confirm`. That route is
**scope `none`**: the client secret is the capability, so a publishable key may call it
without a write scope. Integrators using the element never write this themselves.

The confirm step rejects both `amount` and `currency` with `parameter_unknown`. Rejecting
rather than dropping is deliberate: an integrator who believes they are updating the price
would otherwise ship a checkout that charges the original amount forever, with nothing to
notice.

### `locale`

BCP-47 tags. Today the frame ships English and Spanish: `es`, and anything
beginning `es-` (`es-MX`, `es-ES`), resolve to Spanish. Every other tag falls back to
English rather than failing.

### `fields.billingDetails` and `defaultValues`

`'auto'` draws email, name and street address inside the frame. `'never'` hides all
three. The object form hides only what you name:

```js
fields: { billingDetails: { address: 'never' } }   // email + name still shown
```

Each block takes `'auto'` or `'never'`. `address` takes a third value, `'if_required'`.

**Phone is the exception: `'auto'` does not draw it.** Most payments do not need a phone
number, and an extra field costs conversion, so you opt in explicitly:

```js
fields: { billingDetails: { phone: 'auto' } }
```

#### `address: 'if_required'`

Draws only the address lines the buyer's country actually uses. Pick a country without
a state or province and that field is not drawn at all:

```js
fields: { billingDetails: { address: 'if_required' } }
```

⚠️ **Changing the country clears a state the new country does not have.** Select the US,
type "California", then switch to the United Kingdom: the state is dropped rather than
carried onto a British address it does not belong to. A stale state fails AVS on an
address the buyer got right, and nothing on screen would explain why. Under `'auto'` the
field stays visible, so the value is kept. The buyer can still see and correct it.

Hidden blocks still ride on the charge when you pass `defaultValues.billingDetails`.
That is how a merchant who already has the buyer's address keeps AVS without
drawing the fields. Omit `defaultValues` for a hidden block and that block is
empty on the charge, which costs address verification.

#### The Pay button ends in a paid state

Inside the frame the button runs idle → spinner → green tick, and **stays** on the
paid state: the charge is settled, so it does not fall back to "Pay" and invite a
second press on an intent that has already been charged.

That matters if you tear the element down on `onSuccess`. The buyer sees the tick only
for as long as your page leaves the element mounted — if you unmount immediately they
get no confirmation from us at all, so show your own. Both animations respect
`prefers-reduced-motion`.

#### What the element requires before it reports `complete`

A field the element **draws** must be filled before `onChange` reports
`complete: true`, and a lone space does not count as filled. So a form showing the
billing block reaches `complete` only once email, name, street, city, postal code and
country are all present — plus state or province in the countries whose form draws one
(US, CA, AU, IN, BR, MX). If you gate your own button on `complete`, it stays disabled
until then.

A field you **hide** is never required here: it comes from `defaultValues` instead, so
the short-form integration above is unaffected.

The check is presence, not shape. The element does not validate a postal code against
the country's format — per-country patterns refuse real addresses that the acquirer
would have accepted, and AVS is the authority on that question. The one exception is
`email`, which must at least look like an address, because a receipt has to go
somewhere.

Country is a **list**, not a text field: the buyer picks from all 249 ISO 3166-1
alpha-2 regions, named in their own language. Pass the same two-letter code in
`defaultValues.billingDetails.address.country`. A code the element does not recognise
is kept and shown as-is rather than corrected, since rewriting it would change the
address sent for AVS.

```js
wp.mountPaymentElement('#payment', {
  clientSecret,
  fields: { billingDetails: 'never' },
  defaultValues: {
    billingDetails: {
      email: 'ada@example.com',
      name: 'Ada Lovelace',
      phone: '+44 20 7946 0958',
      address: { line1: '1 Main St', city: 'London', postal_code: 'SW1A 1AA', country: 'GB' },
    },
  },
  onSuccess: (r) => { /* … */ },
});
```

### What crosses into the frame

The client secret, your publishable key, the resolved `apiBase`, appearance, locale,
the billing flag, `defaultValues`, and optionally a customer session secret.
Never an amount. With `mode: 'payment'` the frame gets the mode instead of a secret, and
each attempt's client secret (from `createIntent`) when the buyer pays.

### What comes back out

Ready, a height, a completion state, cancel, an error, and on success the payload below.
No PAN, no CVV, no token, and no vault id. The one id that does cross,
`card_save.payment_method`, is a WorthyPayments `pm_…` payment-method id, not a gateway
vault id or token.

---

## `FrameSuccess`

```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?: FrameCardSave; // whether the card the buyer asked to save was saved
}

interface FrameCardSave {
  status: 'pending' | 'saved' | 'skipped' | 'failed';
  payment_method: string | null;  // 'pm_...' when saved, else null
  failure_code: string | null;    // why, when skipped or failed
  failure_message: string | null; // the gateway's own words, when it gave some
}
```

| Field | Meaning |
|---|---|
| `paymentIntentId` | The intent that was confirmed. |
| `status` | `'succeeded'` or `'processing'`. Never `'requires_payment_method'`. |
| `transactionId` | The charge id. Never `null` on `'succeeded'`. |
| `card_save` | Whether the card was saved, when the buyer ticked the save box. Exactly the four keys above, copied from confirm's `card_save`. See [`card_save`](#card_save). |
| `charge` | The receipt half of the charge: `amount`, `currency`, `status`, `paid`, `captured`, `amount_captured`, `amount_refunded`, `auth_code`, `processor_transaction_id` (gateway / NMI `transactionid`), `statement_descriptor`, `description`, `payment_method_type`, `metadata`, `created`, `billing_details`, `card` (`brand`, `last4`, `exp_month`, `exp_year`), and `failure_code` / `failure_message` on a decline. Not `processor`, `mid`, AVS/CVV, or `card.bin`. |

`charge` is **omitted** rather than null when the read did not answer,
`result.charge?.amount` gives `undefined`, not an object of nulls that reads as "there is
a charge" everywhere you test it.

It is a **named subset** of `GET /v1/charges/{id}`, not all of it. The server charge also
carries the merchant and acquirer facts, `mid`, `processor` (Admin → Acquirers → **Code**
of the acquirer **Payment Workflow** chose for this sale: `NMI` / `PAYARC` / `NUVEI` /
`MAVERICK` as configured, not a hardcoded enum), `processor_label`,
`network_transaction_id`, `arn`, `rrn`, the settlement block, and the `avs_result` /
`cvv_result` verdicts. Those answer a reconciliation question rather than "what did the
buyer just pay", and this payload lands in a document the buyer can read, so they stay
behind your secret key. Empty columns are omitted, not sent as `""`. Publishing
verification verdicts to the page is also how a card-testing script learns which addresses
pass. Those processors all ride one NMI security key; the sale is posted with that
acquirer'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).

`card.bin` is absent for the same reason: bin + last4 is ten of the sixteen digits.
Your own **fraud** verdict is on `GET /v1/charges/{id}/risk` behind `payments:read`.

There is no instrument reference of any kind: no token, no vault id, no fingerprint, no
PAN, no CVV. `card_save.payment_method` is not one: it is a WorthyPayments `pm_…` id, and
charging it takes your secret key or that buyer's own customer session.

**For rendering, not for deciding.** The rule has not moved: fulfil from your server, on
the charge or the `charge.succeeded` webhook. A value that reached your page through a
browser is a value a browser could have edited. What changed is that showing "Paid $49.99
with Visa ••4242" no longer needs a second authenticated round-trip from a flow that is
already holding the outcome.

### `card_save`

Present when the buyer asked to save the card (the element's save box, see
[Saving a card for later](./08b-payment-element.md#saving-a-card-for-later)) and the
intent `succeeded`. **Omitted**, never null, when saving was not requested, on any other
outcome, and when the result could not be read back (then `charge` is omitted too). The
payment's outcome never depends on it.

| `status` | Meaning |
|---|---|
| `'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` (the payment had no customer), `card_not_saveable` (not a fresh card), `payment_not_approved`. |
| `'failed'` | Attempted and failed; the payment stands. `failure_code` is the vault's reason (for example `VAULT_ADD_REFUSED`), `failure_message` the gateway's words when it gave some. |

**`pending` can stay pending, so do not wait on it in the browser.** A save that completes
after the confirm answered sends the `payment_method.attached` webhook 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. Settle a lasting `pending` on your server with
`GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`), never by waiting
for `payment_method.attached`.

**`payment_method` is the `pm_…` form.** Use it on your server: `POST /v1/charges` takes it
as `payment_method.payment_method_id`, and it matches the ids
`GET /v1/customers/{id}/payment_methods` returns. A browser confirm with a saved card takes
it too, with that buyer's customer session, as it takes the bare id
[`savedCards()`](#savedcardscustomersessionsecret) lists (no `pm_` prefix) for the same card.

`FrameCardSave` is not exported by name; type it as `FrameSuccess['card_save']`.

### `status: 'processing'`

The outcome is not known yet. Do not fulfil. Wait for the `charge.succeeded` webhook, or
retrieve the intent (`GET /v1/payment_intents/{client_secret}`): the read asks for the
outcome and never charges. A confirm the browser waited 55 seconds for (or your longer
`timeout`) ends the same way: the SDK retrieves the intent and reports what it reads.

`processing` is also what you get for any state the frame does not recognise, which is
deliberate. Reporting `succeeded` would tell a merchant to ship unpaid goods. Reporting a
decline would tell a buyer whose card *was* charged that it was not.

### Declines

A decline does not throw and does not reach `onSuccess`. It arrives on `onError` with
`type: 'card_error'`, `code: 'card_declined'` and `declineCode`, and the element **stays
mounted** so the buyer can try another card. The intent is still alive. An Apple Pay /
Google Pay decline arrives the same way.

`message` is the sentence the buyer already read, chosen by `declineCode` and in the
element's `locale`; the full table is in
[Payment Element, outcomes](./08b-payment-element.md#3-handle-the-three-outcomes). Branch on
`code` and `declineCode`, never on `message`. Failures that are not declines arrive on
`onError` with their own `code` (for example `request_timeout`, or `amount_changed` on a
wallet payment); branch on `code`, not on `type`.

---

## `PaymentElementHandle`

```ts
interface PaymentElementHandle {
  update(patch: {
    clientSecret?: string; // clientSecret mounts only
    amount?: number;       // mode: 'payment' mounts only
    currency?: string;     // mode: 'payment' mounts only
    appearance?: Appearance;
    locale?: string;
    defaultValues?: { billingDetails?: { email?: string; name?: string; phone?: string; address?: object } };
  }): void;
  submit(): void;
  focus(): void;
  destroy(): void;
}
```

| Method | Behaviour |
|---|---|
| `update(patch)` | Changes the client secret, appearance, locale, or `defaultValues` in place. Every field is optional; omitted fields are left alone. `fields` is mount-only. On a `mode: 'payment'` element it takes `amount` / `currency` instead of `clientSecret`; the wrong one for the mount throws `update_not_supported`. |
| `submit()` | Submits the form. Use it to drive the element from your own button. If the fields are incomplete, the frame shows a validation message instead (and a `mode: 'payment'` element does not call `createIntent`). |
| `focus()` | Moves keyboard focus to the card number field. No-op before `onReady`. |
| `destroy()` | Removes the iframe and its message listener. Safe to call twice; the second call does nothing. |

⚠️ **`destroy()` is permanent. There is no `unmount()`.** A destroyed element cannot be
re-attached; mount a new one. This matters in React: putting `destroy()` in an effect
cleanup means StrictMode's double-invoke, or any route change and return, leaves you with
a dead element. Tie it to real teardown, not to every re-render.

Calls after `destroy()` are ignored rather than throwing, so a late `update()` from an
in-flight effect cannot post a client secret into a frame that is already gone.

⚠️ **`focus()` does not work on iOS.** Safari only moves focus into a cross-origin iframe
in response to a real user gesture, so programmatic focus is silently ignored there. The
same limitation applies to Stripe. Do not build a flow that depends on it, on iOS the
buyer must tap the field.

On a `clientSecret` element, `update()` never takes an amount. When the cart changes, your
server writes the new total to the intent with `PATCH /v1/payment_intents/{id}`, and **the
client secret does not change**: a mounted element needs no `update()` at all. Pass a new
`clientSecret` only when you created a different intent. The price still never travels
through your page. See [When the cart changes](./08b-payment-element.md#when-the-cart-changes).

On a `mode: 'payment'` element there is no intent yet, so `update({ amount })` is how the
wallet buttons learn the new total; they redraw with it. It stays on your page (the frame
never receives an amount), and the intent `createIntent` creates must carry the same total.

---

## `mountExpressCheckout(target, opts)`

Apple Pay and Google Pay buttons on their own, with no card form: on a product page, a
cart, or above your checkout form (Stripe's Express Checkout Element). The buyer chooses
the shipping address and the shipping method inside the sheet. Nothing exists on your
server until the buyer authorizes: `createIntent` then receives the buyer's details from
the sheet, so a guest can pay without typing anything on your page.

A wallet hands back an encrypted credential, never a readable card number, which is why
these buttons can sit on your page. They are drawn only where the device can pay, and only
when `GET /v1/wallet-config` reports the wallet on (see [Wallets](#wallets)).

```js
const express = wp.mountExpressCheckout('#express', {
  amount: 2500,
  currency: 'usd',
  lineItems: [
    { label: 'Subtotal', amount: 2000 },
    { label: 'Standard shipping', amount: 500 },
  ],
  shipping: {
    methods: [
      { id: 'standard', label: 'Standard shipping', amount: 500, detail: '3-5 days', selected: true },
      { id: 'express', label: 'Express shipping', amount: 1500, detail: 'Next day' },
    ],
    // Your server's total for that address: { amount, lineItems, methods } or { error }.
    onAddressChange: (address) => postJson('/express/address', address),
    // Your server's total with that method: { amount, lineItems }.
    onMethodChange: (id) => postJson('/express/method', { id }),
  },
  // Place the order with the sheet's details and create its intent for the authorized total.
  createIntent: async (payment) => (await postJson('/express/order', payment)).client_secret,
  onReady: ({ applePay, googlePay }) => {
    if (!applePay && !googlePay) document.querySelector('#express').hidden = true;
  },
  onSuccess: (result) => { window.location.href = '/thank-you'; },
  onError: (error) => showMessage(error.message),
});
```

`target` is a CSS selector or an element; nothing matching throws `container_not_found`.

| Option | Type | Required | Default | Behaviour |
|---|---|---|---|---|
| `amount` | `number` | Yes | — | The total the sheet opens with, in minor units (`2500` for $25.00). |
| `currency` | `string` | Yes | — | ISO 4217, such as `'usd'`. |
| `lineItems` | `{ label, amount }[]` | No | one "Total" row | The sheet's rows, minor units. A discount is negative. |
| `createIntent` | `(payment: ExpressPayment) => Promise<string>` | Yes | — | Called once the buyer authorized. Create the payment intent for exactly the total the sheet showed last and resolve its `client_secret`. A rejection reaches `onError` as `intent_creation_failed`, and the sheet shows the failure. |
| `onSuccess` | `(result: FrameSuccess) => void` | Yes | — | The payment's outcome, the same [`FrameSuccess`](#framesuccess) as the Payment Element's. |
| `shipping` | `ExpressShipping` | No | — | The order ships: the sheet asks for the address and offers the methods. See [Shipping in the sheet](#shipping-in-the-sheet). |
| `recurring` | `WalletRecurring` | No | — | The payment starts a subscription. See [Subscriptions on the wallet buttons](#subscriptions-on-the-wallet-buttons). |
| `paymentMethods` | `{ applePay?, googlePay? }` | No | `'auto'` | `'never'` leaves that wallet out. |
| `walletButtons` | `WalletButtonsOptions` | No | — | As on the Payment Element ([Wallet buttons](#wallet-buttons)). |
| `borderRadius` | `number` | No | `4` | The buttons' corner radius, px. |
| `locale` | `string` | No | `'en'` | `'es'` for Spanish. |
| `onReady` | `({ applePay, googlePay }) => void` | No | — | Which buttons were drawn. Both `false` (no wallet on this device or account): hide your container. |
| `onError` | `(error: WorthyPaymentsError) => void` | No | — | A payment that did not go through, or a button that could not be drawn (`wallet_error`). |
| `onCancel` | `() => void` | No | — | The buyer closed the sheet. The sheet's figures go back to the mounted ones. Your server may still hold the address the buyer tried, so read your total again and call `update()`. |

### `ExpressPayment`

What `createIntent` receives: everything a guest order needs.

```ts
interface ExpressPayment {
  wallet: 'apple_pay' | 'google_pay';
  billing: {                      // as far as the buyer shared them
    email?: string; name?: string; phone?: string;
    address?: { line1?: string; line2?: string; city?: string; state?: string; postal_code?: string; country?: string };
  };
  shipping?: {                    // only with `shipping`
    name?: string; phone?: string;
    address: { line1?: string; line2?: string; city?: string; state?: string; postal_code?: string; country?: string };
  };
  shippingMethod?: string;        // the id of the method the authorized total includes
}
```

The sheet asks the buyer for an email, name, phone and billing address. Apple Pay and Google
Pay release the shipping name and street lines only once the buyer authorizes, so they
arrive here and never in the shipping callbacks.

### Shipping in the sheet

```ts
interface ExpressShipping {
  methods: ShippingMethod[];                                   // for the address you know; may be []
  onAddressChange(address: ShippingAddress, sheet: { methodId?: string }): Promise<ShippingUpdate>;
  onMethodChange(methodId: string): Promise<ShippingUpdate>;
}
interface ShippingMethod { id: string; label: string; amount: number; detail?: string; selected?: boolean }
interface ShippingAddress { country: string; state: string; city: string; postal_code: string }
interface ShippingUpdate { amount: number; lineItems?: { label: string; amount: number }[]; methods?: ShippingMethod[]; error?: string }
```

- `onAddressChange` receives the four fields the wallets release while the sheet is open,
  and the method the sheet shows selected (`sheet.methodId`). Answer the total for that
  address and its methods. The wallet keeps the selected method when the new address offers
  it, so price that one; when it is not offered, mark the one your total includes
  `selected`.
- `onMethodChange` answers the total with that method.
- An `error`, or an empty `methods` list, says the order cannot ship there. Google Pay
  shows it on the address. Apple Pay cannot show one from these callbacks, so the payment
  is refused when the buyer authorizes (`shipping_unavailable`) and `createIntent` is not
  called. Either way no order is placed for that address.
- A callback that throws, takes longer than 10 seconds (Google's limit), or answers no
  positive whole `amount` is treated the same way.
- Google Pay has no price field for a method, so the SDK writes the price into the label
  ("Standard shipping: $5.00"). Google offers a method choice only when `methods` was not
  empty at mount; with none it asks for the address alone and the total follows it.
- Apple Pay's subscription sheet takes no shipping methods. With `recurring` and
  `shipping` both set, only Google Pay is drawn.

### The authorized total is the charged total

`createIntent` must create the intent for the total the sheet showed last: the `amount`
your latest callback answered, or the mounted one. Before confirming, the SDK reads the
intent, and a different total or currency charges nothing: `onError` receives
`amount_changed`. Read your total again and call `update()`.

### `ExpressCheckoutHandle`

| Method | Behaviour |
|---|---|
| `update({ amount?, currency?, lineItems?, shippingMethods? })` | Redraws the buttons with these figures. Call it whenever your cart changes. `shippingMethods` only on a mount with `shipping`; otherwise it throws `update_not_supported`. |
| `destroy()` | Removes the buttons. |

Thrown at mount or by `update()`: `invalid_amount`, `invalid_currency`,
`missing_create_intent`, `missing_on_success`, `invalid_shipping`,
`invalid_shipping_methods`, `invalid_line_items`, `invalid_payment_methods`,
`container_not_found`. Reaching `onError`: `wallet_error`, `shipping_unavailable`,
`intent_creation_failed`, `invalid_client_secret` (what `createIntent` resolved is not a
payment intent's client secret), `amount_changed`, and the confirm codes of
[Errors](#errors).

---

## `savedCards(customerSessionSecret)`

```ts
savedCards(customerSessionSecret: string): Promise<SavedCard[]>
```

Returns the saved cards of the one buyer a customer session names, for rendering your own
chooser. The element shows them by itself when you pass `customerSessionSecret` to
`mountPaymentElement`, so call this only when you want to build the picker yourself.

```ts
interface SavedCard {
  id: string;                     // the bare payment-method id (no 'pm_' prefix)
  card: {
    brand: string;                // '' when upstream did not supply one
    last4: string;                // '' when upstream did not supply one
    exp_month: number | null;
    exp_year: number | null;
  };
}
```

Brand, last4 and expiry are enough to render a chooser. Vault ids and raw instrument
tokens never reach the browser: the browser picks a card by *our* id, and resolving it to
a real instrument happens server-side, where the key that can move money lives.

Pass `id` unchanged as a browser confirm's saved card. It carries no `pm_` prefix, unlike
`GET /v1/customers/{id}/payment_methods` and `card_save.payment_method`; confirm takes the
`pm_…` form of the same card too.

Resolves to `[]` when the customer has no saved cards.

### Customer sessions

Reading a customer's cards normally needs `customers:read`, and publishable keys cannot
hold that scope: `pk_` is public by construction, and that scope lists your whole customer
book. So your server names the one customer this buyer is. A configured environment
returns `201` with `client_secret`. `400 customer_sessions_unavailable` means the session
HMAC secret is not injected. The route exists and fails closed.

```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": "...",
  "expires_at": 1786000000
}
```

`customer` accepts the `cus_<uuid>` id or the bare uuid, and is answered as `cus_<uuid>`.

**The `client_secret` from that response is what you pass as `customerSessionSecret`.**
The name differs on the wire, and that trips people:

| Response field | SDK parameter |
|---|---|
| `client_secret` | `customerSessionSecret` |

`expires_at` is a Unix timestamp. The session is a signed 30-minute grant scoped to one
customer, one merchant and one mode. A token minted for another merchant is rejected even
though its signature is valid.

`customer` must be non-empty and must not contain `|`; either returns
`parameter_invalid`.

---

## `ping()`

```ts
ping(): Promise<PingResult>
```

Verifies the key and reports its resolved mode. A successful call is itself the "ok".

```ts
interface PingResult {
  object: 'ping';
  mode: 'test' | 'live';
  key_type: string;              // lowercased, e.g. 'publishable'
  merchant_account_id: string;
  api_version: string;
}
```

```js
await wp.ping();
// { object: 'ping', mode: 'live', key_type: 'publishable',
//   merchant_account_id: 'cmt3yfavu00013iokngc9rjtw', api_version: '2026-01-01',
//   scopes: ['payments:charge'], unrestricted: false }
```

There is no `ok` boolean. `scopes` IS returned, but for a publishable key it is always
exactly `['payments:charge']`. The server narrows every `pk_` to that regardless of what
is stored on it, so nothing a page did not already know is disclosed. Mirrors [`GET
/v1/ping`](./11-health.md).

---

## `getFingerprint()`

```ts
getFingerprint(): Promise<string | null>
```

A device fingerprint computed in the browser and cached in `localStorage` for 24 hours.
The element attaches it for you. Call this only when you build a charge server-side and
want the signal.

Returns `null` when fingerprinting is unavailable, which is not fatal.

---

## `getKountSessionId()`

```ts
getKountSessionId(): Promise<string | null>
```

The Kount device-collection session id, or `null` when collection is disabled or
unreachable. The element attaches it on its own.

Never throws and never blocks. Every failure path resolves to `null` and the payment
proceeds without a device signal. See
[Device data collection](./24-device-data-collection.md).

---

## `getDeviceSession()`

```ts
getDeviceSession(): Promise<string | null>
```

A device session id (`ds_…`): this browser's own connection (IP, user agent, fingerprint),
recorded by `POST /v1/device_sessions` with your publishable key. Send it to your server and
put it on [`POST /v1/charges`](./03-charges.md) as `device_session`, and a charge your server
sends carries the buyer's IP and browser instead of your server's. The element needs none: it
confirms from the buyer's browser.

Call it when the buyer pays. A session lasts 30 minutes; the SDK caches it and fetches a new
one while the cached one still has ten minutes left, and concurrent calls share one request.
Resolves `null` on any failure, and a charge without a session goes through as before. See
[Device data collection](./24-device-data-collection.md#device-session-the-buyers-ip-and-browser).

---

## Appearance

```ts
interface Appearance {
  theme?: 'default' | 'night' | 'flat';   // 'stripe' is a deprecated alias for 'default'
  variables?: Record<string, string>;
  rules?: Record<string, Record<string, string>>;
}
```

You name variables and we apply them. Themes are applied first, so your own variables
override the preset: picking `night` and then naming `colorPrimary` gives you the dark
palette with your accent.

```js
appearance: {
  theme: 'night',
  variables: {
    colorPrimary: '#7C3AED',
    borderRadius: '12px',
    fontFamily: 'Inter, sans-serif',
  },
  rules: {
    '.Input': { boxShadow: 'none' },
    '.Input:focus': { borderColor: '#7C3AED' },
  },
}
```

An unknown **theme** name is reported as ignored. An unknown **variable** or property name
is dropped silently, so a typo costs you the styling and not the checkout.

### Variables

`colorPrimary`, `colorBackground`, `colorText`, `colorTextSecondary`, `colorDanger`,
`colorSuccess`, `colorBorder`, `colorIcon`, `colorPrimaryText`, `fontFamily`,
`fontSizeBase`, `fontSizeSm`, `fontWeightNormal`, `fontWeightMedium`, `borderRadius`,
`borderWidth`, `spacingUnit`, `spacingGridRow`, `boxShadow`, `focusBoxShadow`,
`focusOutline`.

Each maps to a CSS custom property: `colorPrimary` becomes `--wp-color-primary`.
`colorPrimaryText` is the Pay button label (default white). `boxShadow` is the
resting elevation on `.Root` (the outer container), not on every input. Input
shadows go through `rules['.Input']`.

**The Pay button.** When you name `colorPrimary`, the button is filled with it (a slight
brightening on hover). A theme's own primary does not repaint the button: `night`'s is a
light accent that white button text would be unreadable on. `rules['.Button']` and
`rules['.Button:hover']` with `backgroundColor` win over both. Its wording and whether it
is drawn at all are the `payButton` option, not appearance.

The button is always in one of four states, and carries one class for each state but the
first: ready to pay (no class), `.Button--disabled` (the form is not complete yet),
`.Button--loading` (the payment is in progress) and `.Button--paid`. A state's rule wins
over `.Button` and `.Button:hover`, whatever order you write them in, so a loading button
under the pointer still looks loading. A `backgroundColor` on a state replaces the default
gradient for that state.

```js
rules: {
  '.Button--disabled': { opacity: '0.6' },
  '.Button--loading': { backgroundColor: '#5B21B6' },
}
```

**Fonts.** The frame is its own document on its own origin, so it cannot use a font your
page loads. It serves **Inter, Roboto, Open Sans, Lato, Montserrat and Poppins** itself:
name one of those in `fontFamily` (in any case, quoted or not) and the form renders in
it. Any other family falls back down your stack, so end it with a generic family
(`sans-serif`).

### Rule selectors

`.Root`, `.Input`, `.Input:focus`, `.Input--invalid`, `.Input::placeholder`, `.Label`,
`.Error`, `.Block`, `.Button`, `.Button:hover`, `.Button--disabled`, `.Button--loading`,
`.Button--paid`, `.SavedItem`, `.SavedItem--selected`, `.SavedItem:hover`, `.Hint`,
`.SectionTitle`, `.Field`, `.Row`, `.Checkbox`, `.CheckboxInput`, `.CheckboxLabel`,
`.CardBrand`. A selector outside that list is dropped, so a rule cannot reach arbitrary
DOM.

| Selector | Is |
|---|---|
| `.Input::placeholder` | The placeholder text in every field |
| `.Checkbox`, `.CheckboxInput`, `.CheckboxLabel` | The "save this card" row, its checkbox and its text (`fields.savePaymentMethod: 'auto'`). Colour the tick with `accentColor` on `.CheckboxInput`. |
| `.CardBrand` | The card network mark drawn in the card-number field |
| `.SavedItem:hover` | A saved card under the pointer. It does not apply to the selected card, so that one keeps its `.SavedItem--selected` look. |
| `.Button--disabled`, `.Button--loading`, `.Button--paid` | The Pay button's states (see [The Pay button](#variables) above) |

Rules are applied in the order of this list, not the order you write them, so a state
always wins over what it modifies.

### Rule properties

`color`, `backgroundColor`, `border`, `borderColor`, `borderStyle`, `borderWidth`,
`borderRadius`, `boxShadow`, `outline`, `outlineColor`, `outlineOffset`, `padding` (and
the four sides), `margin` (and the four sides), `fontFamily`, `fontSize`, `fontWeight`,
`fontStyle`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration`, `opacity`,
`transition`, `width`, `minWidth`, `maxWidth`, `height`, `minHeight`, `maxHeight`,
`overflow`, `overflowX`, `overflowY`, `textAlign`, `gap`, `accentColor`.

Box-size **inside** the frame is allowed: `rules['.Root']` can set `maxWidth`,
`maxHeight` and `overflowY: 'auto'` so the form narrows or scrolls. Leaving the
flow is not. `position`, `display`, `flex`, `transform`, `zIndex` are dropped:
those are how a compromised appearance would paint over the host page.

### What appearance does not control

| You want | You do this |
|---|---|
| Iframe **box** on the page | Size the host `#payment`. The iframe is 100% of that box. |
| Form **narrower than the host** | `rules['.Root']`: `{ maxWidth: '420px', marginLeft: 'auto', marginRight: 'auto' }` |
| **Scroll** inside a short column | `rules['.Root']`: `{ maxHeight: '480px', overflowY: 'auto' }` |
| Checkout **column card** (page-level drop shadow) | Style your own wrapper. The iframe canvas is transparent on purpose. |
| **No border** on a block | `rules['.Input']`: `{ border: 'none', boxShadow: 'none' }` |

### Value rules

Values are checked against a positive allowlist rather than a set of blocked patterns.

| Rule | Detail |
|---|---|
| Character set | Only `a-z A-Z 0-9`, whitespace, and `# % ( ) . , + - _ / ' "` |
| Length | 200 characters maximum |
| Comments | `/*` and `*/` are refused outright |
| Functions | Only `rgb`, `rgba`, `hsl`, `hsla`, `var`, `calc`, `min`, `max`, `clamp` |
| `var()` | May only reference `--wp-` names |

Anything outside that alphabet is dropped. That covers escaping the declaration (`;`,
`{`) as well as every spelling of a network request: `url()`, `image-set()`, `src()`,
`\75 rl()`, `u/**/rl()`, `attr()`.

`backgroundImage` is not settable at all. Every use of it is an outbound request from
inside the payment frame, and aimed at `.Input:focus` or `.Input--invalid` that request
becomes a beacon reporting the moment a buyer reached the card field, or the moment a
number failed its checksum.

---

## Wallets

Apple Pay and Google Pay mount on the **merchant page, directly above the card iframe**.
The same place payment links put them. Wallets are not card data, so they stay on your
origin (the one Apple and Google already know). The PAN stays in the iframe. The Payment
Element needs no separate wallet mount and nothing to wire. For the buttons without a card
form (a product page, a cart, shipping chosen in the sheet), use
[`mountExpressCheckout()`](#mountexpresscheckouttarget-opts).

The sheet total is loaded from the intent, so it is the server amount. A buyer dismissing
the sheet fires `onCancel`.

**They may be off.** `GET /v1/wallet-config` often returns `provider: "NONE"` with
`applePay: false` and `googlePay: false`. That is the default, not an error: the SDK
mounts no buttons. Enable in the Merchant Dashboard under **Settings → Checkout Settings →
Digital Wallets** (Test: **Create test wallet** on that page, then the switches; Live:
support must provision the wallet merchant first). Mount a button only on a literal
`true`. Device/browser/wallet state still decides whether a button appears on a given
buyer. An absent wallet section on a Linux desktop is the correct outcome even when the
account flag is on.

### Wallet buttons

Apple and Google draw their own buttons (Apple as a shadow-DOM component, Google as an
iframe), so neither takes your page's CSS or `appearance`. `walletButtons` sets the presets
each one offers:

```js
walletButtons: {
  applePay: { theme: 'white-outline', type: 'subscribe' },
  googlePay: { theme: 'white', type: 'subscribe' },
  height: 44,
}
```

| Option | Values | Default |
|---|---|---|
| `applePay.theme` | `black`, `white`, `white-outline` | `black` |
| `applePay.type` | `add-money`, `book`, `buy`, `check-out`, `continue`, `contribute`, `donate`, `order`, `pay`, `plain`, `reload`, `rent`, `set-up`, `subscribe`, `support`, `tip`, `top-up` | Apple's own |
| `googlePay.theme` | `black`, `white` | `black` |
| `googlePay.type` | `book`, `buy`, `checkout`, `donate`, `order`, `pay`, `plain`, `short`, `subscribe` | Google's own |
| `height` | 40 to 55 (px), both buttons | `48` |

A value outside its list, or a height outside 40-55, is ignored and that setting keeps its
default. The two providers spell some types differently (`check-out` for Apple,
`checkout` for Google). The corner radius follows `appearance.variables.borderRadius`, and
the "Or pay with card" line under the buttons uses your page's font.

### Subscriptions on the wallet buttons

```ts
interface WalletRecurring {
  description: string;          // shown on the Apple Pay sheet
  billingAgreement: string;     // the terms, shown before the buyer authorizes
  managementUrl: string;        // https page to manage or cancel
  regularBilling: {
    label: string;
    amount: number;             // minor units, above zero
    intervalUnit?: 'day' | 'week' | 'month' | 'year'; // default 'month'
    intervalCount?: number;     // default 1
    startDate?: string | Date;  // default now
  };
  trialBilling?: { label: string; amount: number; startDate?: string | Date };
}
```

With `recurring` set, the Apple Pay sheet opens as Apple's recurring payment request
(Google Pay's web API has an equivalent, `recurringTransactionInfo`, which the SDK does not
send yet), and a wallet payment sends
`save_payment_method: true` on its confirm, so `FrameSuccess.card_save` reports the
outcome. Google Pay then offers only the cards it hands back as the card itself
(`PAN_ONLY`), which are saved and can renew. An Apple Pay payment answers
`card_save.status: 'skipped'`, `failure_code: 'card_not_saveable'` for now (its device or
merchant token cannot be saved yet), so collect a card for renewals, or leave Apple Pay out
of a subscription with `paymentMethods: { applePay: 'never' }` on the express element.
The intent needs a `customer`. A malformed value reaches `onError` and the Apple Pay button
is not drawn. `WalletRecurring` and `RecurringIntervalUnit` are exported types. Evervault
wallet rail only. Full walkthrough:
[Payment Element](./08b-payment-element.md#subscriptions-paid-by-wallet).

---

## 3-D Secure

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, nothing is shown. There
is nothing to wire.

Paygeyt has no `return_url` field. Stripe uses that for a full-page redirect after 3DS;
here the element stays on the checkout page, so `onSuccess` is the return path.

Driving 3DS yourself, from a native app or a server-side flow, is
[3-D Secure](./07-3ds.md).

---

## Domain allowlist

The element refuses to render unless the embedding origin is on your key's allowed
domains. `frame-ancestors` is computed per request from that list (via
`GET /v1/element-frameable`), and the browser enforces it. A refusal shows as a
**blank frame**, not an error, which is the shape most first-run problems take here.

The origin under test is the `Origin` header. Query `origin` is optional and must not
carry a loopback URL on the public edge (`?origin=http://localhost…` is HTML 403; omit the
query and send `Origin` instead). The hosted SDK already does that; you do not put
`localhost` in the iframe URL. See [Local development](#local-development) and [Errors,
loopback in the URL](./10-errors.md#loopback-in-the-url).

**Important:** An empty allowlist does not open the element. Framing fails closed: no domains
configured → `frame-ancestors 'none'` → blank iframe. That is the opposite of the
charge API's DomainGuard, which treats an empty list as "no origin lock" for
server-to-server calls. For the Payment Element you must have the list set.

Wildcards match a single label. `*.shop.com` covers `www.shop.com` and not
`a.b.shop.com`, so list each host you actually embed on. A wildcard does **not** cover
the apex either: `*.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 once it is
non-empty.

Set the list when you create the key in **Settings → Developers → API keys** (**Allowed
domains**). Update it on the key later without rolling the secret. A new checkout origin
does not need a new `pk_`.

---

## Errors

`onError` receives a `WorthyPaymentsError`:

```ts
class WorthyPaymentsError extends Error {
  readonly type: string;
  readonly code: string;
  readonly status?: number;       // HTTP status, for transport failures
  readonly declineCode?: string;
}
```

### Codes from the element

| Code | Cause |
|---|---|
| `card_declined` | The issuer declined. The element stays mounted for a retry. |
| `payment_failed` | The confirm threw for a reason that is not a decline. |
| `element_error` | The frame reported an error with no code of its own. |
| `intent_creation_failed` | `mode: 'payment'`: your `createIntent` rejected; `message` is your error's. Nothing was charged and the buyer can try again. |
| `invalid_client_secret` | `mode: 'payment'`: `createIntent` resolved something that is not a client secret. Nothing was charged. |

### Codes from confirm

| Code | Cause |
|---|---|
| `parameter_unknown` | You sent `amount` or `currency` to confirm. The intent fixed those. Refused, not ignored. |
| `parameter_missing` | No instrument. Pass exactly one. |
| `parameter_invalid` | Two instruments at once. Pass exactly one. |
| `secret_key_in_browser` | An `sk_` reached the browser. Use the publishable key and a client secret. |
| `resource_missing` | No such intent for this key's merchant and mode. |

Two instruments is refused rather than resolved. Only one can be charged, so the other
would be dropped silently and you could bill something you did not intend.

### Codes thrown at construction or mount

| Code | Thrown by | Cause |
|---|---|---|
| `missing_key` | Factory | No key, or not a string. |
| `invalid_key` | Factory | Key does not start with `pk_`. |
| `secret_key_in_browser` | Factory | Key starts with `sk_`. |
| `container_not_found` | `mountPaymentElement` | No element matches `target`. |
| `client_secret_with_mode` | `mountPaymentElement` | Both `clientSecret` and `mode` were passed. Pass one. |
| `invalid_mode` | `mountPaymentElement` | `mode` is not `'payment'`. |
| `invalid_amount` | `mountPaymentElement`, `update()` | With `mode: 'payment'`, `amount` is not a positive integer in the smallest currency unit. |
| `invalid_currency` | `mountPaymentElement`, `update()` | With `mode: 'payment'`, `currency` is not a three-letter code. |
| `missing_create_intent` | `mountPaymentElement` | `mode: 'payment'` without a `createIntent` function. |
| `update_not_supported` | `update()` | `amount` / `currency` on a `clientSecret` element (PATCH the intent instead), or `clientSecret` on a `mode: 'payment'` element. |
| `invalid_client_secret` | `mountPaymentElement` | `clientSecret` is not shaped like one. Pass the `client_secret` from `POST /v1/payment_intents`, either `pi_..._secret_<token>` or the bare token. A placeholder used to mount a full, working-looking card form and only fail when the buyer pressed Pay. **Shape only:** a well-formed secret for an intent that is not yours still fails at confirm, where ownership can actually be checked. |
| `invalid_frame_origin` | `mountPaymentElement` | `elementOrigin` is not an absolute URL. |
| `insecure_frame_origin` | `mountPaymentElement` | `elementOrigin` is not https. |
| `same_origin_frame` | `mountPaymentElement` | `elementOrigin` equals your own origin. |

Full catalogue: [Error codes](./30-error-codes.md).

---

## TypeScript

Types are re-exported from the package entry point:

```ts
import WorthyPayments, {
  type Appearance,
  type FrameSuccess,
  type PingResult,
  type WorthyPaymentsOptions,
  WorthyPaymentsError,
} from '@worthypayments/js';
```

`PaymentElementOptions`, `PaymentElementHandle`, `PaymentElementUpdate` (what `update()`
takes) and `SavedCard` resolve from the same import:

```ts
import type {
  PaymentElementOptions,
  PaymentElementHandle,
  PaymentElementUpdate,
  SavedCard,
} from '@worthypayments/js';
```

The express element's types resolve from it too: `ExpressCheckoutOptions`,
`ExpressCheckoutHandle`, `ExpressCheckoutUpdate`, `ExpressPayment`, `ExpressBilling`,
`ExpressShipping`, `ShippingMethod`, `ShippingAddress`, `ShippingSheet`, `ShippingUpdate`, `SheetLineItem`
and `WalletsDrawn`.

The package deliberately exports nothing beyond the types listed here. If a name you
expect does not resolve, it belongs to the internals rather than the integration.

### Not part of the public API

`InitPayload`, `UpdatePayload` and `IntentReply` are exported because the iframe imports
them from the same package. They are the internal postMessage contract between the parent and the frame.
They will change without a major version, and nothing in an integration should reference
them.

### Script-tag statics

Script-tag users get `WorthyPayments.WorthyPaymentsError` on the global factory, which is
what you need to recognise an SDK error.

The bundle also carries `isChargeError`, `isRequiresAction` and `isSucceeded` as statics.
They narrow a raw `POST /v1/charges` response and predate the element, which reports its
outcome through `onSuccess` and `onError` instead. You will not need them in an element
integration, and they are not importable from the package in TypeScript.

---

## Putting the element in a modal

⚠️ **Escape will not close your dialog by itself.** A keypress inside a cross-origin
iframe never reaches the embedding page, so once the buyer clicks into the card number
(the field they spend the longest in) your `keydown` listener stops hearing anything. The
dialog looks broken, and no amount of debugging your own handler explains it.

`onEscape` is the fix:

```js
wp.mountPaymentElement('#payment', {
  clientSecret,
  onEscape: () => closeModal(),
  onSuccess: (r) => { /* … */ },
});
```

Two more things worth knowing for modals:

- **Mount after the dialog is in the DOM.** Mounting into a detached node throws
  `container_not_in_document`; an iframe inside a node that is not in the document never
  loads, so it fails immediately rather than hanging.
- **`focus()` will not work on iOS**, so do not rely on auto-focusing the card field when
  the dialog opens.

## Content-Security-Policy

If you send a CSP (and a checkout page should) the element needs these. It is served from
its own origin precisely so that card data never enters your page's scope, which means
`frame-src` is not optional:

```
frame-src   https://element.worthypayments.com
script-src  https://app.worthypayments.com
connect-src https://api.worthypayments.com
```

A page that loads the SDK from `app.paygeyt.com` uses the paygeyt.com hosts instead
(`element.paygeyt.com`, `app.paygeyt.com`, `api.paygeyt.com`); see
[Two domains](./README.md#two-domains).

⚠️ **Miss `frame-src` and the form never appears at all**. No `onError`, because the
browser blocks the frame before any of our code runs inside it. What you get instead is a
CSP violation in the console and, ten seconds later, `element_load_failed`. Check CSP
first when the element is blank.

Add these only for the features you actually use:

| Feature | Directive |
|---|---|
| Apple Pay | `script-src https://applepay.cdn-apple.com` |
| Google Pay | `script-src https://pay.google.com` and `frame-src https://pay.google.com` |
| Apple Pay / Google Pay (the SDK loads Evervault on your page to mount them), or a 3DS challenge you run yourself on your page | `script-src https://js.evervault.com`, `frame-src https://*.evervault.com`, `connect-src https://*.evervault.com` |
| Kount device data | `frame-src https://*.kaptcha.com` |

3-D Secure through the Payment Element needs nothing on your CSP: the challenge opens
inside the element's frame, whose own policy already allows Evervault.

You do not need to allowlist bank domains: the issuer's ACS page loads inside
Evervault's frame, which `https://*.evervault.com` already covers.

**Cross-origin isolation (COOP/COEP) breaks the element**, as it does Stripe's. If your
page sets `Cross-Origin-Embedder-Policy: require-corp`, the frame will not load.

## Safari and Apple Pay: device checklist

Skip this unless `GET /v1/wallet-config` reports `applePay: true`. Automating it is not
reliable, so run it by hand before calling wallet support done on a release.

1. The domain allowlist includes the host that embeds the element.
2. CSP and frame layout allow `applepay.cdn-apple.com`.
3. The iframe has `allow="payment"` (the SDK sets this; confirm nothing strips it).
   Express wallets still mount on the merchant page above that iframe.
4. On a real iPhone in Safari: the Apple Pay button appears, the sheet opens, the amount
   matches the intent, cancelling fires `onCancel`, and success returns the intent id.
5. On desktop Safari with no wallet configured: the unavailable hint shows and nothing
   crashes.

---

## Related

- [Payment Element walkthrough](./08b-payment-element.md)
- [Authentication](./02-authentication.md)
- [3-D Secure](./07-3ds.md)
- [Errors](./10-errors.md) and [Error codes](./30-error-codes.md)
- [Webhooks](./16-webhooks.md) and [Events](./19-events.md)
- [Device data collection](./24-device-data-collection.md)
- [Going live](./21-going-live.md)
