Embedded SDK (`worthypayments.js`)
View as MarkdownReference 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#
<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?)#
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.
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.
localhostand127.0.0.1are 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://localhostin a public URL query. Local checkout with a test key is allowed. The browser tells the API who it is with theOriginheader. 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: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}whenlocalhostis on the key. See Errors, loopback in the URL. http://[::1]is refused forelementOriginonly. It is fine as your page's origin: the allowlist check treatslocalhost,127.0.0.1and[::1]alike. The restriction is on the option naming where the frame is served from,apiBaseaccepts[::1],elementOrigindoes not. Use127.0.0.1there. 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 withmax-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,onErrordoes 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#
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#
| 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. |
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. |
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 for where this value comes from. |
appearance | Appearance | No | — | Theme, variables and rules. See 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). |
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. Mount-only. |
recurring | WalletRecurring | No | — | This payment starts a subscription. See 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 })):
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.
amountis what the wallet sheet shows; the payment charges the amount on the intentcreateIntentreturns. 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
createIntentcharges nothing. A rejection reachesonErrorasintent_creation_failed, with your error's message; a value that is not a client secret asinvalid_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 })(seePaymentElementHandle) 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:
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:
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:
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.
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#
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. |
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.
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.
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() 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#
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.
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).
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 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. |
recurring | WalletRecurring | No | — | The payment starts a subscription. See 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). |
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.
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#
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 }
onAddressChangereceives 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 includesselected.onMethodChangeanswers the total with that method.- An
error, or an emptymethodslist, 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) andcreateIntentis 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
amountis 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
methodswas 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
recurringandshippingboth 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.
savedCards(customerSessionSecret)#
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.
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.
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"}'
{
"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()#
ping(): Promise<PingResult>
Verifies the key and reports its resolved mode. A successful call is itself the "ok".
interface PingResult {
object: 'ping';
mode: 'test' | 'live';
key_type: string; // lowercased, e.g. 'publishable'
merchant_account_id: string;
api_version: string;
}
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()#
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()#
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()#
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#
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.
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.
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 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().
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:
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#
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:
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.
TypeScript#
Types are re-exported from the package entry point:
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:
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:
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:
| 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.
- The domain allowlist includes the host that embeds the element.
- CSP and frame layout allow
applepay.cdn-apple.com. - The iframe has
allow="payment"(the SDK sets this; confirm nothing strips it). Express wallets still mount on the merchant page above that iframe. - 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. - On desktop Safari with no wallet configured: the unavailable hint shows and nothing crashes.
