Embedded SDK (`worthypayments.js`)

View as Markdown

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 before you call one.

For a walkthrough of taking a first payment, read Payment Element. 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 prefixResult
pk_Accepted
sk_Throws secret_key_in_browser
Anything elseThrows invalid_key
Missing or not a stringThrows missing_key

WorthyPaymentsOptions#

OptionTypeDefaultBehaviour
apiBasestringhttps://api.worthypayments.com/v1 when the SDK was loaded from a worthypayments.com/sdk/ URL (as in these guides); https://api.paygeyt.com/v1 otherwiseWhere 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.
elementOriginstringhttps://element.worthypayments.com when the SDK was loaded from a worthypayments.com/sdk/ URL (as in these guides); https://element.paygeyt.com otherwiseWhere 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).
timeoutnumber15000Per-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.
debugbooleanfalseVerbose 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.
  • 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.
  • 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.)

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.

PaymentElementOptions#

OptionTypeRequiredDefaultBehaviour
clientSecretstringYes, unless mode—From POST /v1/payment_intents on your server. Addresses the intent that holds the amount. See 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.
amountintegerWith 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.
currencystringWith 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.
customerSessionSecretstringNo—Shows that one buyer's saved cards inside the element. See Customer sessions for where this value comes from.
appearanceAppearanceNo—Theme, variables and rules. See Appearance.
localestringNo'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).
onReady() => voidNo—The frame has loaded and been initialised.
onChange(state: { complete: boolean; empty: boolean }) => voidNo—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 }Noblack, provider's wording, 48How the Apple Pay and Google Pay buttons look. See Wallet buttons. Mount-only.
recurringWalletRecurringNo—This payment starts a subscription. See Subscriptions on the wallet buttons. Mount-only.
onSuccess(result: FrameSuccess) => voidYes—The only required callback.
onError(err: WorthyPaymentsError) => voidNo—Declines and faults. Without it, errors are silent to your page.
onLoadError(err: WorthyPaymentsError) => voidNo—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() => voidNo—The buyer dismissed a wallet sheet. The card form has no cancel.
onEscape() => voidNo—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) 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.

FieldTypeRequiredNotes
amountintegerYesSmallest currency unit. 4999 is $49.99. A decimal is rejected rather than rounded, because 49.99 would otherwise charge 49 cents.
currencystringNoISO 4217. Defaults to usd.
descriptionstringNoShown on the charge.
customerstringNoAn 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_emailstringNo*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.
referencestringNoYour own order or cart id.
idempotency_keystringOne of header or bodySame 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
}
FieldMeaning
paymentIntentIdThe intent that was confirmed.
status'succeeded' or 'processing'. Never 'requires_payment_method'.
transactionIdThe charge id. Never null on 'succeeded'.
card_saveWhether 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.
chargeThe 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.

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) 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.

statusMeaning
'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() 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. 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;
}
MethodBehaviour
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.

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).

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.

OptionTypeRequiredDefaultBehaviour
amountnumberYes—The total the sheet opens with, in minor units (2500 for $25.00).
currencystringYes—ISO 4217, such as 'usd'.
lineItems{ label, amount }[]Noone "Total" rowThe 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) => voidYes—The payment's outcome, the same FrameSuccess as the Payment Element's.
shippingExpressShippingNo—The order ships: the sheet asks for the address and offers the methods. See Shipping in the sheet.
recurringWalletRecurringNo—The payment starts a subscription. See Subscriptions on the wallet buttons.
paymentMethods{ applePay?, googlePay? }No'auto''never' leaves that wallet out.
walletButtonsWalletButtonsOptionsNo—As on the Payment Element (Wallet buttons).
borderRadiusnumberNo4The buttons' corner radius, px.
localestringNo'en''es' for Spanish.
onReady({ applePay, googlePay }) => voidNo—Which buttons were drawn. Both false (no wallet on this device or account): hide your container.
onError(error: WorthyPaymentsError) => voidNo—A payment that did not go through, or a button that could not be drawn (wallet_error).
onCancel() => voidNo—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#

MethodBehaviour
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.


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 fieldSDK parameter
client_secretcustomerSessionSecret

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.


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.


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 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.


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.

SelectorIs
.Input::placeholderThe placeholder text in every field
.Checkbox, .CheckboxInput, .CheckboxLabelThe "save this card" row, its checkbox and its text (fields.savePaymentMethod: 'auto'). Colour the tick with accentColor on .CheckboxInput.
.CardBrandThe card network mark drawn in the card-number field
.SavedItem:hoverA 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--paidThe Pay button's states (see The Pay button 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 wantYou do this
Iframe box on the pageSize the host #payment. The iframe is 100% of that box.
Form narrower than the hostrules['.Root']: { maxWidth: '420px', marginLeft: 'auto', marginRight: 'auto' }
Scroll inside a short columnrules['.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 blockrules['.Input']: { border: 'none', boxShadow: 'none' }

Value rules#

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

RuleDetail
Character setOnly a-z A-Z 0-9, whitespace, and # % ( ) . , + - _ / ' "
Length200 characters maximum
Comments/* and */ are refused outright
FunctionsOnly 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().

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,
}
OptionValuesDefault
applePay.themeblack, white, white-outlineblack
applePay.typeadd-money, book, buy, check-out, continue, contribute, donate, order, pay, plain, reload, rent, set-up, subscribe, support, tip, top-upApple's own
googlePay.themeblack, whiteblack
googlePay.typebook, buy, checkout, donate, order, pay, plain, short, subscribeGoogle's own
height40 to 55 (px), both buttons48

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.


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.


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 and Errors, 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#

CodeCause
card_declinedThe issuer declined. The element stays mounted for a retry.
payment_failedThe confirm threw for a reason that is not a decline.
element_errorThe frame reported an error with no code of its own.
intent_creation_failedmode: 'payment': your createIntent rejected; message is your error's. Nothing was charged and the buyer can try again.
invalid_client_secretmode: 'payment': createIntent resolved something that is not a client secret. Nothing was charged.

Codes from confirm#

CodeCause
parameter_unknownYou sent amount or currency to confirm. The intent fixed those. Refused, not ignored.
parameter_missingNo instrument. Pass exactly one.
parameter_invalidTwo instruments at once. Pass exactly one.
secret_key_in_browserAn sk_ reached the browser. Use the publishable key and a client secret.
resource_missingNo 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#

CodeThrown byCause
missing_keyFactoryNo key, or not a string.
invalid_keyFactoryKey does not start with pk_.
secret_key_in_browserFactoryKey starts with sk_.
container_not_foundmountPaymentElementNo element matches target.
client_secret_with_modemountPaymentElementBoth clientSecret and mode were passed. Pass one.
invalid_modemountPaymentElementmode is not 'payment'.
invalid_amountmountPaymentElement, update()With mode: 'payment', amount is not a positive integer in the smallest currency unit.
invalid_currencymountPaymentElement, update()With mode: 'payment', currency is not a three-letter code.
missing_create_intentmountPaymentElementmode: 'payment' without a createIntent function.
update_not_supportedupdate()amount / currency on a clientSecret element (PATCH the intent instead), or clientSecret on a mode: 'payment' element.
invalid_client_secretmountPaymentElementclientSecret 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_originmountPaymentElementelementOrigin is not an absolute URL.
insecure_frame_originmountPaymentElementelementOrigin is not https.
same_origin_framemountPaymentElementelementOrigin equals your own origin.

Full catalogue: Error codes.


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.

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:

FeatureDirective
Apple Payscript-src https://applepay.cdn-apple.com
Google Payscript-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 pagescript-src https://js.evervault.com, frame-src https://*.evervault.com, connect-src https://*.evervault.com
Kount device dataframe-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.