Payment Element
View as MarkdownThe Payment Element is a payment form we serve in an iframe and you drop into your page.
It collects the card, runs 3-D Secure when enabled for the account, shows saved cards,
and calls you back when the money is captured. Apple Pay and Google Pay mount on the
merchant page, directly above the card iframe. Not inside it. They are off until GET /v1/wallet-config reports them on. See Wallets and 3-D Secure,
which is also where the CSP entries they need are listed.
Because the fields live in our iframe and your scripts cannot read them, your page stays on the PCI SAQ A path (~30 requirements) rather than SAQ A-EP (~139). That is the reason the element exists.
<script src="https://app.worthypayments.com/sdk/v1/worthypayments.umd.js"></script>
Load the pinned /sdk/v1/ bundle. The unversioned /sdk/worthypayments.umd.js
always serves the newest build, which turns every deploy of ours into an untested
change to your live checkout.
What the element draws, and what stays yours#
The element renders a complete payment form and nothing else:
| Inside the iframe (ours) | On your page (yours) |
|---|---|
| Card number, expiry, CVC, and the network's mark once the number identifies one | The amount the buyer is paying |
| Name on card | Order summary, line items, shipping |
| Billing email, full name, address, city, postal, state, country | Your header, branding and layout |
| The Pay button | Terms, trust marks, support links |
| 3-D Secure and saved cards | The success and failure screens |
Apple Pay and Google Pay are the exception: they mount on YOUR page, not in the
frame. The SDK inserts them itself, in a block directly above the card form, because a
wallet sheet has to be opened by the page the buyer is actually on — that is the origin
Apple Pay checks against your registered domains. You do not wire them up (they appear whenever the key has
wallets enabled, unless you pass wallets: 'never'), but the block is in your document,
so its width and the space around it follow your layout.
The total is not in the form. The amount lives on the intent, and the element never displays it, so a buyer who sees only the iframe is pressing Pay without a figure in front of them. Render the total yourself, next to the element. This is the same rule from the other side: the browser cannot name the price, and it does not show it either.
The appearance option restyles what is inside the frame so it does not look pasted onto
your page. It cannot move fields or add copy. See Styling.
While the frame loads, the element holds empty space the size of the form that is coming — it does not draw a stand-in form. Reserving the height keeps your page from jumping when the real form arrives; drawing a fake one would make that arrival look like the content changing under the buyer.
Before anything renders: allowlist your domain#
The element refuses to render for an origin that is not on your key's allowed domains, and a refusal looks like a blank frame rather than an error. It is the most common first-run failure.
Set the domains on your publishable key when you create it in Settings → Developers →
API keys (Allowed domains field; required for publishable keys). List the exact
hostnames that will embed the element. Update the list on the key later without rolling
the secret. A new checkout origin does not need a new pk_. The parent page must be
served over https. Loopback HTTP is the TLS exception for a local development server:
http://localhost, http://127.0.0.1, and http://[::1]. Opening a downloaded HTML file
(file://) is not an origin we frame. List the hostname (localhost) on the key like any
other domain. The secret key stays on your server; only pk_ belongs in the page.
Checkout framed by another of your sites. The browser checks every page above the
card form, not only the one that mounts it. If shop.example.com mounts the element and
is itself framed by dash.example.com, list both on the key. Chrome, Edge and Safari
report the whole chain; Firefox reports only the page directly above, so on Firefox one
level of framing is supported.
Local checkout with that key is allowed. The page identifies itself with the Origin
header; the SDK already sends it. Do not put http://localhost in a public URL query
(GET /v1/element-frameable?origin=http://localhost:3000, or ?parent=http://localhost…
on the element host). The public edge answers HTML 403 before the API runs. That is not a
ban on local development. Probe with the header and no query:
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
200 {"allowed":true} means the key will frame that origin. See Embedded SDK (local
development and Errors) loopback in the
URL.
Wildcards match a single label, so *.shop.com covers www.shop.com but not
a.b.shop.com, and it does not cover the apex: *.shop.com leaves shop.com
refused, so list both if buyers check out at both. The same list decides which origins
may charge with the key, which is why it is not a styling detail.
Important: An empty allowlist does not open the element. Framing fails closed: no domains configured → blank iframe. That is the opposite of the charge API's origin guard, which treats an empty list as "no origin lock" for S2S calls. Set the list before you embed.
The flow has two steps#
1. YOUR SERVER POST /v1/payment_intents (sk_) -> client_secret
2. BROWSER wp.mountPaymentElement('#payment', { clientSecret, onSuccess })
Your server names the price. The browser gets a secret that addresses that decision, not the decision itself, so a buyer editing your page cannot change what they pay.
If your checkout only creates the order when the buyer pays, swap the two steps: mount
first with mode: 'payment', and create the intent in createIntent at submit. See
Mount before the order exists.
The same mount collects the first invoice of a subscription. POST /v1/subscriptions
with payment_behavior=default_incomplete (or without a saved card) returns status: "incomplete" and latest_invoice.payment_intent.client_secret, pass that secret here. Do
not put plan or interval on the Element; the server already named the plan. See
Subscriptions.
1. Create the intent (your server)#
Requires secret key + scope payment_intents:create (server-only).
Idempotency-Key is required. The browser later confirms with scope none: the
returned client_secret is the capability, not a second write scope.
curl https://api.worthypayments.com/v1/payment_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: ord_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"description": "Order #1234",
"reference": "ord_1234",
"receipt_email": "buyer@example.com"
}'
| Field / header | Required | Notes |
|---|---|---|
amount | Yes | Minor units integer (4999 = $49.99). Decimals rejected. |
currency | No | Defaults to usd |
description | No | Line label |
reference | No | Your order id (echoed later) |
metadata | No | Your own key/value pairs, same rules as on POST /v1/charges (50 keys, keys up to 40 characters without [/], string values up to 500). Answered to your server only, and copied onto the charge the intent makes. See Metadata and the other details |
customer | No | An existing customer of this key's merchant and mode: the cus_… id GET /v1/customers returns, the bare uuid, or the CUS-… number. The intent answers with the cus_… form. Any other value is a 400 resource_missing with param: customer (No such customer: '…'), and the same key replays it |
receipt_email | No | Receipt destination. Send this or billing.email on confirm, with neither, the confirm fails failed_precondition / customer email is required |
three_ds | No | { "request": "any" | "challenge" | "challenge_mandated" } runs 3-D Secure on the card the buyer enters, whatever your rules decide (EMV indicators 01 / 03 / 04); default automatic leaves it to your rules, which still win when they ask. The Element runs the challenge, on a saved card too; only a saved card with no encrypted copy (saved before 2026-10-18, or imported) is re-entered first (3-D Secure on a saved card); a wallet or a card sealed on the wp: rail pays without it. Read back on every intent as three_ds.request. See 3-D Secure |
Idempotency-Key | Yes | Header or body idempotency_key (stable per order) |
{
"id": "pi_4b124c602f49458b92204957ee5b21b6",
"object": "payment_intent",
"amount": 4999,
"currency": "usd",
"status": "requires_payment_method",
"client_secret": "pi_4b124c602f49458b92204957ee5b21b6_secret_rz2MzQr49QlZ3TW2UWq5cMI6xb9dd0wUMl7h"
}
Send only client_secret to the page. It appears on create only. Base URL:
https://api.worthypayments.com/v1.
2. Mount the element (browser)#
Copy this HTML, replace the two placeholders, and serve it from an origin on the
publishable key's allowed domains. Pin the /sdk/v1/ bundle. onSuccess is required.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Checkout</title>
<!--
If you send a CSP, the element needs at least:
frame-src https://element.worthypayments.com
script-src https://app.worthypayments.com
connect-src https://api.worthypayments.com
Miss frame-src and the form never appears (onLoadError after 10 s). Wallet / 3DS / Kount
extras: https://app.worthypayments.com/developers/08-embedded-sdk.md (Content-Security-Policy).
-->
<script src="https://app.worthypayments.com/sdk/v1/worthypayments.umd.js"></script>
</head>
<body>
<div id="payment"></div>
<script>
// clientSecret comes from YOUR SERVER: POST /v1/payment_intents (sk_).
const clientSecret = 'pi_…_secret_…';
const wp = WorthyPayments('pk_live_xxx');
const element = wp.mountPaymentElement('#payment', {
clientSecret,
onSuccess: (r) => {
// required. r.paymentIntentId, r.status, r.transactionId,
// r.charge?.processor_transaction_id (NMI transactionid)
if (r.status === 'succeeded') {
// show confirmation; fulfil on charge.succeeded
} else {
// 'processing' — do not fulfil; wait for the webhook
}
},
onError: (e) => {
// declines land here, not onSuccess; the element stays mounted
},
onLoadError: (e) => {
// no form at all (allowed domains, CSP, network): offer a checkout link instead
},
});
// element.update({ clientSecret?, appearance?, locale?, defaultValues? })
// — never an amount. Cart changes PATCH the intent on the server;
// the client secret does not change, so a mounted element needs nothing.
// element.submit() — drive Pay from your own button
// element.destroy() — permanent; mount a new element to show the form again
</script>
</body>
</html>
Card entry, field validation, saved cards and retries happen inside the iframe. Apple Pay and Google Pay mount on the merchant page, directly above that iframe. 3-D Secure is driven by the element; a challenge opens inside the iframe, which grows to fit it, and your page is covered while it runs (The 3-D Secure overlay).
| Option | Required | Default | When you need it |
|---|---|---|---|
clientSecret | Yes, unless mode | — | From POST /payment_intents |
mode, amount, currency, createIntent | Instead of clientSecret | — | Mount before the intent exists; your server creates it when the buyer pays. See Mount before the order exists |
onSuccess | Yes | — | Fulfil UI only; money confirmation still via webhook |
onError | No | silent | Declines / faults (card_declined, …). Element stays mounted. |
onLoadError | No | silent | The form could not load at all (element_load_failed, after 10 s): usually the key's allowed domains or your CSP. Not a decline: fall back to a checkout link, never "card declined". |
customerSessionSecret | No | — | Returning buyer saved cards (from POST /customer_sessions) |
fields.billingDetails | No | auto | 'never' or { email, name, address } each 'auto'|'never' |
defaultValues.billingDetails | No | — | Prefill, and values for hidden blocks (keeps AVS) |
fields.savePaymentMethod | No | never | 'auto' draws an untickable-by-default "save this card" box, except on an intent created with setup_future_usage, which saves the card anyway. See Saving a card |
appearance | No | — | Theme variables inside the frame |
payButton | No | — | { label: 'Start membership' } for your own wording, or { display: 'never' } to draw no button and call element.submit() from yours |
locale | No | en | es, es-MX, es-ES, … |
onReady / onChange / onCancel | No | — | Load state, field completeness, wallet sheet dismiss |
onEscape | No | — | The buyer pressed Escape inside the frame; close your own modal. Not called while 3-D Secure runs |
wallets | No | auto | 'never' when your page already mounts Apple Pay / Google Pay |
walletButtons | No | black, 48 px | { applePay: { theme, type }, googlePay: { theme, type }, height } — the providers' own button presets. See Embedded SDK |
recurring | No | — | This payment starts a subscription: the Apple Pay sheet shows its terms, and a wallet payment asks to save its method. See Subscriptions paid by wallet |
Handle methods: element.update({ clientSecret?, appearance?, locale?, defaultValues? })
(never an amount on a clientSecret mount; fields is mount-only), element.submit(),
element.destroy().
When the cart changes, your server writes the new total to the intent with PATCH /v1/payment_intents/:id. The client secret does not change, so
the mounted element needs nothing from you.
Full types: Embedded SDK.
Three ready-made forms#
The one option that changes what the buyer sees is fields.billingDetails. These three
settings cover most checkouts. Each has a runnable page: open it, paste your own test
keys, pay with a test card, and read what came back on the charge.
| Form | The frame draws | Mount with | Try it |
|---|---|---|---|
| 1 · Card only | Card number, expiry, CVC, name on card | fields: { billingDetails: 'never' } and defaultValues.billingDetails from your page | /demos/element-card-only.html |
| 2 · Full billing | Card + email, name, street, city, postal code, state where the country has one, country from a list of all 249 regions | nothing: 'auto' is the default | /demos/element-full-billing.html |
| 3 · Smart address | Card + email, name, and only the address lines the buyer's country uses | fields: { billingDetails: { address: 'if_required' } } | /demos/element-smart-address.html |
Card only is for a page that already holds the buyer: an account checkout, a subscription upgrade, a second purchase. The buyer sees no billing field, and the charge still carries the address, because you pass it:
wp.mountPaymentElement('#payment', {
clientSecret,
fields: { billingDetails: 'never' },
defaultValues: {
billingDetails: {
email: 'ada@example.com',
name: 'Ada Lovelace',
address: { line1: '1 Main St', city: 'Austin', state: 'TX',
postal_code: '78701', country: 'US' },
},
},
onSuccess: (r) => { /* r.charge.billing_details carries that address */ },
});
onSuccess hands the address back under charge.billing_details, which is how the demo
page shows that AVS had its input. Two rules come with this form. email is not optional
unless the intent carries receipt_email: a confirm with neither fails customer email is required. And hiding the block without defaultValues is the case the warning below
this section is about: no address reaches the issuer. If the buyer changes after the
element is mounted, element.update({ defaultValues }) takes the new values. You can
call it right before element.submit(), in the same tick, to hand over the billing form
on your own page at the moment of payment: the submit confirms the values the update
carried. address.line2 rides on the charge with line1; the element draws no field for
it, and drops it when the buyer types a different street.
Full billing is the default and needs no option. The element reports complete: true
only once every drawn field is filled, and the country is a list, not a text box, so a
typo cannot reach the acquirer as an AVS country.
Smart address keeps street and postal code, which AVS checks, and drops the state or
province line in the countries whose addresses have none. Pick the United States, then
the United Kingdom, and watch the line go. It asks nothing of your page, unlike 'never'.
phone is off in all three, because no card rail needs it. { phone: 'auto' } inside
the object form draws it.
What comes back#
The iframe confirms with POST /v1/payment_intents/:client_secret/confirm (publishable
key). That HTTP body is a payment_intent (id, object, status, latest_charge,
amount, currency, customer, … plus a receipt charge). The SDK maps it to
FrameSuccess before onSuccess. The parent page does not receive the extra intent
fields.
interface FrameSuccess {
paymentIntentId: string;
status: 'succeeded' | 'processing';
transactionId: string | null;
charge?: FrameCharge; // named subset of GET /v1/charges/{id}, not the whole object
card_save?: { // only when the buyer ticked "save this card"; see below
status: 'pending' | 'saved' | 'skipped' | 'failed';
payment_method: string | null; // 'pm_...' when saved
failure_code: string | null;
failure_message: string | null;
};
}
{
"paymentIntentId": "pi_79bb4da94cac40d383ee3d0403b5f8be",
"status": "succeeded",
"transactionId": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
"charge": {
"id": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
"object": "charge",
"amount": 1999,
"currency": "usd",
"status": "succeeded",
"paid": true,
"captured": true,
"amount_captured": 1999,
"amount_refunded": 0,
"auth_code": "TESTOK",
"processor_transaction_id": "TEST-2a7af918-7888-4d74-87c0-45c800ea7477",
"payment_method_type": "card",
"metadata": {},
"created": 1788217437,
"card": { "brand": "visa", "last4": "4242" }
}
}
charge is present whenever it could be read back and omitted when it could not, enough
to render "Paid $49.99 with Visa ••4242" without a second round-trip.
processor_transaction_id is the gateway's own id (NMI transactionid).
It is a named subset. The merchant and acquirer facts on the server charge (mid,
processor, processor_label, network_transaction_id, arn, rrn, settlement,
and the AVS / CVV verdicts) stay behind your secret key: they answer a different
question than what the buyer just paid, and this payload lands in a page the buyer
can read. Read them on GET /v1/charges/{id} (payments:read) or the
charge.succeeded webhook. Empty columns are omitted, not sent as "".
processor is Admin → Acquirers → Code of the acquirer Payment Workflow chose for
this sale (NMI, PAYARC, NUVEI, MAVERICK, … as configured. Not a fixed string). All
of those processors share the merchant's one NMI security key; the NMI processor_id on
the sale is that row's Connector code (processor_id) / charge code. Connector
codes map raw gateway keys onto the same row. Neither is the public processor field.
See Charges.
No instrument reference crosses the boundary: no PAN, no CVV, no token, no vault id, and
no card.bin. card_save.payment_method is not one either: it is a WorthyPayments pm_…
id, chargeable only with your secret key or that buyer's own customer session. brand and
last4 are what the buyer typed and what their receipt shows.
Use them to display, not to decide, fulfilment still reads the charge on your server or
the charge.succeeded webhook.
Keep fields.billingDetails at 'auto' unless you already collect that block on
your page. Then hide it ('never' or { address: 'never' }) and pass the
known values in defaultValues.billingDetails so they still ride on the charge.
Hiding the address without
defaultValuessends the charge with no AVS, and that is not a cosmetic loss. AVS is the issuer's check of the billing address against the one on file. Submitting nothing means the issuer scores the authorization without it, which shows up as a lower approval rate and, in a dispute, as a weaker position. An unverified address is one of the first things a chargeback file is judged on. The element cannot cover for you here.'never'says "I collect this myself", and the frame has no way to see what your page collected, so it cannot tell a merchant who passed the address indefaultValuesfrom one who dropped it. The only safe reading of'never'withoutdefaultValuesis "I accept charging without AVS". If you already have the buyer's address, hide the block and pass it:fields: { billingDetails: { address: 'never' } }, defaultValues: { billingDetails: { address: { line1: '1 Main St', city: 'Austin', state: 'TX', postal_code: '78701', country: 'US' }, }, },
3. Handle the three outcomes#
onSuccess with status: 'succeeded'. The money is captured. You can show a
confirmation, though fulfilment should still key off the webhook.
onSuccess with status: 'processing'. The outcome is not known yet: the card
network did not tell us in time whether the card was charged. Do not fulfil. Wait for
charge.succeeded, or retrieve the intent (GET /v1/payment_intents/{client_secret})
every few seconds. A retrieve asks for the outcome and never charges; the intent moves
to succeeded, or to requires_payment_method with
last_payment_error.decline_code: 'payment_not_completed' when nothing was charged and
the buyer can pay again. While the buyer is still in a 3-D Secure challenge it reads
requires_action, never "not charged". Never confirm again to find out: a confirm is a payment
attempt, not a status read. processing is also the fallback for any state we do not
recognise, on purpose: reporting succeeded would tell you to ship goods that may not
be paid for, and reporting a decline would tell a buyer whose card was charged that
it was not.
A confirm that is still running after 55 seconds (or after the longer timeout you
configured) is settled the same way: the SDK stops
waiting, retrieves the intent (a read that never charges) and reports succeeded or
processing from it. Only when that read fails too does onError receive
code: 'request_timeout', and the outcome is then unknown: check the intent or wait for
the webhook before letting the buyer pay again.
onError with code: 'card_declined'. A decline does not throw and does not
arrive on onSuccess. The element stays mounted and the intent stays alive, so the
buyer can try another card without you re-creating anything. status on onSuccess
is never 'requires_payment_method'. The error's type is 'card_error', for the card
form and for Apple Pay / Google Pay alike.
The error carries declineCode when the decline has one (insufficient_funds,
do_not_honor, duplicate_transaction, processing_error, …): the same value as the
intent's last_payment_error.decline_code. message is the wording the element already
showed the buyer, in the element's locale (English and Spanish):
| The decline | What the buyer reads |
|---|---|
duplicate_transaction (the gateway refused it as a repeat of a payment just made) | "This looks like a duplicate of a payment you just made." |
insufficient_funds | "Your card was declined for insufficient funds. Try another card." |
incorrect_cvc / cvv_mismatch | "Your card's security code is incorrect." |
incorrect_zip | "Your card's postal code is incorrect." |
incorrect_address | "Your card's billing address is incorrect." |
expired_card | "Your card has expired. Try another card." |
incorrect_number / invalid_number | "Your card number is incorrect." |
amount_too_large / amount_too_small | "This business can't accept a payment of this amount. You have not been charged." |
currency_not_supported | "This business can't accept payments in this currency. You have not been charged." |
merchant_unavailable | "This business can't accept payments right now. You have not been charged." |
test_mode_live_card | "Your card was declined. This payment is in test mode: use a test card." |
soft_decline_3ds on a saved card (3-D Secure is needed; the card form opens, see 3-D Secure on a saved card) | "Your bank needs to verify this card. Enter the card details again to continue. You have not been charged." |
three_ds_failed (the 3-D Secure session did not authenticate) | "Your bank could not verify this card. Try again or use another card." |
payment_not_completed, try_again_later, issuer_unavailable, and any other refusal by the gateway before the card network saw it (last_payment_error.network_status: 'not_sent_to_network') | "The payment could not be processed. Please try again." |
everything else, an issuer processing_error and the fraud family (fraudulent, lost_card, stolen_card, pickup_card) included | "Your card was declined. Try another card." |
The postal code, address, amount and currency lines come from your own Payment Workflow rules: the payment was refused before it reached the gateway, and nothing was charged.
onError: (e) => {
if (e.declineCode === 'duplicate_transaction') {
// the buyer may already have paid: check your orders before letting them pay again
}
},
Do not let a buyer retry a duplicate_transaction blindly: look the payment up
(GET /v1/charges, or your charge.succeeded webhook) first. Read a gateway refusal from
last_payment_error.network_status, never from the code: processing_error is also an
issuer answer. See Gateway refusals.
Fulfil on the webhook, not in the browser#
| Event | Meaning |
|---|---|
charge.succeeded | Money captured. Fulfil now. |
charge.failed | It will not settle. |
Match an event to your order with data.object.detail.orderReference: it is the
reference you set when your server created the intent, echoed back. Set it. A decline
carries it too, while the browser never receives a transactionId for a decline, so the
charge id alone cannot tie a charge.failed to an order. The intent's metadata rides along
too, as data.object.detail.metadata. Without a reference, use the
payment-intent fallback in Webhooks.
Configure the endpoint in Settings -> Developers -> Webhooks and verify the signature.
See Events.
Do not fulfil from a browser poll. The buyer may close the tab the moment they pay, and a
charge that settles two seconds later would then never be fulfilled. A page may retrieve
a processing intent to show the buyer its outcome, but fulfilment keys off the webhook,
which arrives regardless of what the page is doing.
When the cart changes#
The buyer adds an item, picks a different shipping option, applies your own discount. Write the new total to the intent from your server:
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_4b124c602f49458b92204957ee5b21b6 \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"amount": 7500, "description": "Order #1234"}'
Note the two things this route does differently from every other one on this page:
- It takes the
pi_...id, not the client secret. The secret is in the buyer's page. If a re-price were addressed by it, a buyer could re-price their own order, which is precisely the decision the element exists to keep on your server. Reaching this route needs the server-only scopepayment_intents:create, so apk_cannot call it at all. - The client secret does not change. There is nothing to hand the browser and no
element.update()to call; a mounted element keeps working and the buyer pays the new amount.
Apple Pay and Google Pay buttons drawn before the change are covered too. A wallet sheet
shows the total it was drawn with, so a wallet payment whose sheet still showed the old
total is refused before any charge: onError receives code: 'amount_changed'
(type: 'invalid_request_error'), and the buttons redraw with the new total for the buyer
to tap again.
Only an unpaid intent moves. Once the buyer has submitted and a charge is in flight, the
call fails with 400 failed_precondition rather than quietly doing nothing, so a late cart
edit can never disagree with what the card was actually charged. An expired or paid intent
answers the same.
Retrying is safe. The body names the amount the intent should end up at, not a delta, so
sending it twice lands on the same total. That is why no Idempotency-Key is required here,
unlike create.
Do not create a second intent for a changed cart. It still works, but it leaves the first one to expire unpaid, and the browser then has to be handed a fresh
clientSecretthroughelement.update({ clientSecret }). Patching is one call and the page never learns the price.
Metadata and the other details#
metadata is your own key/value pairs on the intent: an order id, a cart id, whatever ties the
payment to your system. Set it on create, change it with the same PATCH, and change
reference, customer and receipt_email there too. Send only what changes:
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_4b124c602f49458b92204957ee5b21b6 \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"reference": "ord_1235", "metadata": {"order_id": "8812", "coupon": ""}}'
metadatais merged, as on Stripe. A key you send is set, a key sent as""is removed (couponabove), and keys you do not send stay. To empty it, send each key with"";metadata: nullis refused. The intent holds at most 50 keys.referenceandreceipt_emailare replaced;""ornullclears them.customeris checked like on create and replaces the intent's customer. It cannot be removed, and it cannot change once a payment attempt on the intent started, a declined one included (400 failed_precondition,param: customer): that attempt, and a card it saved, belong to the customer the intent had then. A card already saved never moves.- The same states as the amount: an intent that is paid, being paid or expired refuses
every change with
400 failed_precondition. - Who sees it.
metadatacomes back withreferenceon create, onPATCH, and on retrieve with a secret or restricted key. The publishable key in the buyer's page never receives either, so you can keep the buyer's details or your internal ids there. - The charge copies it. When the buyer pays, the charge takes the intent's
metadataonce, as it is at that moment:GET /v1/charges/{id}carries it asmetadata, thecharge.*webhooks asdetail.metadata, andcheckout.session.completedasmetadata. Editing the intent after a declined attempt reaches the next attempt's charge, never the declined one.
Mount before the order exists#
Some checkouts only know the order when the buyer presses "Place order" (WooCommerce, a cart
that is priced on submit). Mount the element first, without a client secret, and create the
intent on your server when the buyer pays. This is Stripe's
elements({ mode: 'payment', amount, currency }).
const element = wp.mountPaymentElement('#payment', {
mode: 'payment',
amount: 1099, // the total, smallest currency unit
currency: 'usd',
// Runs when the buyer pays: create the intent with your secret key, return its secret.
createIntent: async () => {
const res = await fetch('/checkout/create-intent', { method: 'POST' });
if (!res.ok) throw new Error('Could not create the order');
return (await res.json()).client_secret;
},
onSuccess: (r) => { /* as above */ },
onError: (e) => { /* declines, and intent_creation_failed when createIntent threw */ },
});
// The cart changed before the buyer paid:
element.update({ amount: 2099 });
- The form is complete and the buyer presses Pay (or you call
element.submit(), or a wallet sheet is approved). - The element calls
createIntent. Your server runsPOST /v1/payment_intentswith the order's real total and returns theclient_secret. - The element confirms that intent (3-D Secure included) and calls
onSuccessoronError, exactly as aclientSecretmount does.
What to know:
- Your server still names the price.
amountis what the Apple Pay / Google Pay sheet shows; the charge is the amount on the intent you create. Create it with the same total. A wallet payment whose intent disagrees with the sheet is refused before any charge (amount_changed). - An incomplete form never calls
createIntent, so no unpaid intent is left behind. Each attempt calls it again; your server may hand back the same unpaid intent's secret for a retry after a decline. - If
createIntentthrows, nothing is charged:onErrorreceivesintent_creation_failedwith your error's message, and the element tells the buyer the payment could not be started and lets them try again. - When the cart changes, call
element.update({ amount })so the wallet buttons redraw. There is no intent to PATCH yet.update({ clientSecret })is refused on this mount.
Full reference: Embedded SDK.
Saving a card for later#
A saved card is the other half of the section below: something has to enrol the card before there is anything to show a returning buyer, and before a subscription has a stored method to bill (see Customers & saved cards).
Turn the offer on at mount:
wp.mountPaymentElement('#payment', {
clientSecret,
fields: { savePaymentMethod: 'auto' }, // draws the checkbox, UNTICKED
onSuccess: (r) => { /* .. */ },
});
The element then draws a "Save this card for future payments" checkbox under the
card fields. The buyer ticking it is the consent, and the card is enrolled only on that
tick. Default is 'never': no box, nothing saved.
Three things about this that are deliberate and not configurable:
- The box is never pre-ticked, and there is no option to pre-tick it. A pre-ticked box is not valid consent under GDPR, and the same reasoning holds for card-network stored-credential rules.
- It only appears for a fresh card. A buyer paying with a card they already saved sees no box, because there is nothing to enrol.
- Nothing is saved without it. Absent consent the card is stored nowhere: no payment method on your account, and no vault entry at the gateway either.
You are responsible for the terms that go with this. Before you enable it, state on your own page what you will use the saved card for, when you will charge it, and how the buyer cancels, and keep a record of their agreement. The checkbox collects consent for storage; it is not a substitute for your terms.
Attach the payment to a customer so the card has somewhere to live: pass customer on
POST /v1/payment_intents. It must be an existing
customer of the same merchant and mode as your key, or the create answers 400
resource_missing. That customer is the payment's and the card's owner, whatever email
the buyer types in the Element; the email is only where the receipt goes. Without
customer, the buyer's email decides: the payment and the card attach to the customer
with that email, created if none exists.
Always saving the card (subscriptions)#
When the card must be saved whatever the buyer does, for example the first payment of a
subscription you renew yourself, create the intent with setup_future_usage and a
customer:
curl https://api.worthypayments.com/v1/payment_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: sub_42_first" \
-H "Content-Type: application/json" \
-d '{ "amount": 2900, "currency": "usd",
"customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f",
"setup_future_usage": "off_session" }'
- The card is saved to that customer once the payment is approved. The element draws no
"save this card" box for this intent, even with
fields.savePaymentMethod: 'auto', and the buyer's page cannot turn the save off. - With a deferred mount (
mode: 'payment', the intent created increateIntent), passsetupFutureUsage: 'off_session'(or'on_session') tomountPaymentElementas well, so the element draws no box before the intent exists, and create each intent with the samesetup_future_usage. The intent is what saves the card; the option only shapes the form. On aclientSecretmount the option is refused (setup_future_usage_with_client_secret). off_session: you will charge the card without the buyer present (renewals).on_session: the buyer will pay with it again at your checkout. Both save the card the same way.- This payment is the card's initial stored-credential transaction. Later
merchant-initiated charges on the saved card (
recurring: true,initiated_by: "merchant"onPOST /v1/charges) refer to it. customeris required: without it the create is a 400parameter_missingwithparam: customer.- The result is
card_save, read as below. A wallet payment (Apple Pay, tokenised Google Pay) cannot be saved: it succeeds and answersskipped/card_not_saveable. A saved card the buyer picks is already on file and carries nocard_save. - The box was the buyer's consent; without it, consent is your checkout's job. State on your page that the card is kept for later payments and how they are charged.
Did the card get saved?#
When the buyer ticked the box and the payment succeeded, onSuccess receives
card_save, copied from the confirm response (the same object
GET /v1/payment_intents/{client_secret} returns for that intent):
onSuccess: (r) => {
if (r.card_save?.status === 'saved') {
showSavedNotice(r.card_save.payment_method); // 'pm_...'
}
},
{
"paymentIntentId": "pi_79bb4da94cac40d383ee3d0403b5f8be",
"status": "succeeded",
"transactionId": "TXN-605d3df5-6dd5-4f57-b31f-d9bdee7196a4",
"card_save": {
"status": "saved",
"payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
"failure_code": null,
"failure_message": null
}
}
card_save.status | What it means |
|---|---|
saved | The card is saved for the customer (newly, renewed, or it already was). payment_method is its pm_… id. |
pending | The save may still be running, or its outcome is unknown. It can stay pending. |
skipped | Not attempted. failure_code: customer_missing (no customer on the payment), card_not_saveable (not a fresh card, e.g. a wallet), payment_not_approved. |
failed | Attempted and failed; the payment still succeeded. failure_code names the reason (for example VAULT_ADD_REFUSED), failure_message carries the gateway's words when it gave some. |
card_save is omitted, never null, when the box was not ticked, when the payment did
not succeed, and when the result could not be read back (then charge is omitted too).
Saving never changes the payment's outcome.
Do not wait for a webhook to settle pending. A save that completes after the confirm
answered sends payment_method.attached only for a newly saved card,
payment_method.updated when it renewed a card the customer already had (same card, new
expiry), and no event at all when that card was already saved with the same expiry. Re-reading
the intent does not move a pending either. To settle it, read the customer's cards on your
server with GET /v1/customers/{id}/payment_methods (secret key, customers:read).
Two forms of the same id. card_save.payment_method is the pm_… form, which is what
your server uses: POST /v1/charges takes it as payment_method.payment_method_id, and it
matches GET /v1/customers/{id}/payment_methods. The customer-session list below
(savedCards(), GET /v1/customer_sessions/payment_methods) returns the same card by its bare
id, without the pm_ prefix. A browser confirm with a saved card takes either form, with the
customer session of the buyer who owns the card.
Saving a card without a payment (setup intents)#
A $0 trial sign-up, "add a payment method" on an account page, and a new card for a subscription all need a saved card and no charge. Your server opens a setup intent for an existing customer:
curl https://api.worthypayments.com/v1/setup_intents \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: signup_42_card" \
-H "Content-Type: application/json" \
-d '{ "customer": "cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f", "usage": "off_session" }'
and mounts the element with its client_secret (seti_…_secret_…), exactly as for a payment:
wp.mountPaymentElement('#card', {
clientSecret: setupIntentClientSecret,
onSuccess: (r) => {
// r.setupIntentId, r.payment_method ('pm_...'); r.paymentIntentId is '' — nothing was paid
saveOnMyServer(r.payment_method);
},
onError: (e) => { /* e.code: card_declined (with e.decline_code) or processing_error */ },
});
- The element draws the card fields (and the billing fields you ask for) with a Save card button. It shows no saved cards, no save box and no wallet buttons: a wallet's device token cannot be saved.
- The card is verified with a $0 authorization and saved to the customer in the same gateway
request, as the card's initial stored-credential transaction. Charge it later with
POST /v1/chargesandpayment_method: { payment_method_id: 'pm_…' }; a renewal isrecurring: true,initiated_by: "merchant". - A declined card leaves the form open for another card. After five failed attempts the setup intent accepts no more: create a new one.
- 3-D Secure runs when you create the setup intent with
three_ds: { request: 'challenge' }(orany/challenge_mandated); the element shows the bank's challenge itself. - Confirm on your server from
setup_intent.succeeded(it names the saved card) or by retrieving the intent, not from the browser callback alone. A failed attempt sendssetup_intent.setup_failed. usage(off_session, the default, oron_session) says what you will do with the card; both save it the same way. As with a payment that saves a card, tell the buyer at your page that the card is kept for later payments.- Each attempt meets the same fraud screening as a payment (your blocked customers and
cards, card-testing detection, your velocity rules) before the card is verified. A refusal
reads like a decline:
last_setup_errorcard_declinedwithdecline_codefraudulent, and it counts toward the five attempts. Which rule fired is never answered.
Mount before the setup intent exists. As with mode: 'payment',
pass mode: 'setup' and a createIntent that creates the setup intent on your server when
the buyer presses Save card, and resolves its client_secret:
wp.mountPaymentElement('#card', {
mode: 'setup',
createIntent: async () => {
const r = await fetch('/my-server/setup-intents', { method: 'POST' });
return (await r.json()).client_secret; // 'seti_..._secret_...'
},
onSuccess: (r) => saveOnMyServer(r.payment_method),
});
It takes no amount, currency or setupFutureUsage (amount_with_setup_mode,
setup_future_usage_with_setup_mode), and a payment intent's secret from createIntent is
refused (invalid_client_secret), so a Save card button can never charge.
Cancel or list. POST /v1/setup_intents/{id}/cancel (secret key, customers:write) ends
an open setup intent, for a buyer who left the form or a duplicate you created; pass
cancellation_reason (abandoned | duplicate | requested_by_customer) if you track it.
A canceled intent takes no card. A succeeded one cannot be canceled: its card is saved, so
detach the card instead. GET /v1/setup_intents (secret key, customers:read) lists your
setup intents newest first, ?customer= for one customer's.
Changing a subscription's card#
A subscription's buyer who wants to pay with another card saves it with a setup intent for the subscription's customer (above), then your server points the subscription at it:
curl -X PATCH https://api.worthypayments.com/v1/subscriptions/sub_9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d \
-H "x-api-key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "default_payment_method": "pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e" }'
Nothing is charged now; the next renewal charges the new card. The card must be an active
saved card of the subscription's own customer (another customer's card, or one you detached,
is a 404). A canceled subscription is refused. A past_due subscription keeps its retry
schedule, and its next retry charges the new card.
Saved cards#
To show a returning buyer their cards, create a customer session on your server
(scope customer_sessions:create, secret key). It is a short-lived grant for one
customer. Publishable keys cannot list your customer book.
A configured environment returns 201 with client_secret. 400 customer_sessions_unavailable means this environment has no session HMAC secret
(CUSTOMER_SESSION_TOKEN_SECRET): the endpoint exists and fails closed rather than
minting an unsigned grant. Do not treat the 400 as a missing route.
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": "cs_live_xxx",
"expires_at": 1754160000
}
customer accepts the cus_<uuid> id or the bare uuid, and is answered as cus_<uuid>.
The session lasts 30 minutes. expires_at is a Unix timestamp, so mint it when the buyer
reaches checkout rather than when the page first loads.
The client_secret from this response is what you pass as
customerSessionSecret. It is not the payment intent's client secret, and the two
names trip people up.
wp.mountPaymentElement('#payment', {
clientSecret, // from /v1/payment_intents
customerSessionSecret: 'cs_live_xxx', // from /v1/customer_sessions
onSuccess: (r) => { /* .. */ },
});
The element renders the picker itself. If you want your own chooser instead,
savedCards() returns the list:
const cards = await wp.savedCards(customerSessionSecret);
// [{ id: '6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e', card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2029 } }]
You get brand, last4 and expiry, which is enough to render a chooser. Vault ids and
instrument tokens stay server-side. id is the bare payment-method id (no pm_ prefix);
pass it unchanged when you confirm with the saved card.
Creating customers is in Customers.
3-D Secure on a saved card#
When a payment on a saved card needs 3DS — your intent's three_ds.request asked for
it, or one of your rules did (the route's Attempt 3DS, the 3DS risk band, a customer
marked Require 3DS, another fraud rule; see
When does a step-up happen?) — the element runs
the challenge on the saved card itself, as it does for a typed card. This works because a
card saved through the element keeps an encrypted copy of its number that only our
encryption provider can decrypt. The buyer does not type anything.
A card saved before 2026-10-18, or imported from your gateway vault, has no encrypted copy.
Its attempt is declined with decline_code: soft_decline_3ds and nothing is charged.
The element then handles it:
- It hides the saved cards and opens the card form, with the message "Your bank needs to verify this card. Enter the card details again to continue. You have not been charged."
- The buyer types the card again and presses Pay. That card runs the challenge like any card entered for this payment, and is charged when it authenticates. When it is the same card, its encrypted copy is kept on the saved card, so the buyer is not asked again next time.
onError fires once for the first attempt, with code: card_declined,
decline_code: soft_decline_3ds and that message. The element stays mounted and the
intent stays payable, as with any decline: do not unmount it or show a failure screen.
The "Save this card" box is not offered again, because the card is already saved.
The same decline on a card the buyer just typed is the issuer's own soft decline and is shown as a normal decline.
Styling#
You name variables, we apply them inside the frame.
wp.mountPaymentElement('#payment', {
clientSecret,
appearance: {
variables: {
colorPrimary: '#7C3AED',
colorText: '#E8EAED',
colorBackground: 'transparent',
borderRadius: '12px',
fontFamily: 'Inter, sans-serif',
},
rules: {
'.Input': { boxShadow: 'none' },
'.Input:focus': { borderColor: '#7C3AED' },
},
},
onSuccess: (r) => { /* .. */ },
});
You can recolor, restyle, narrow, and scroll the form inside the frame. You cannot
lift it out of the iframe (position, display, transform and backgroundImage
are dropped). The iframe box is still the host #payment. Unknown variables are ignored.
Full list: Embedded SDK.
The Pay button is filled with the colorPrimary you name (rules['.Button']
backgroundColor overrides it); without one it keeps the default look. Change its
wording with payButton: { label }, or hide it with payButton: { display: 'never' }
and call element.submit() from your own button. Its states have their own selectors
(.Button--disabled, .Button--loading, .Button--paid), as do placeholders
(.Input::placeholder), the "save this card" checkbox (.Checkbox, .CheckboxInput,
.CheckboxLabel), the card brand mark (.CardBrand) and a saved card under the pointer
(.SavedItem:hover). For fonts, the frame serves Inter,
Roboto, Open Sans, Lato, Montserrat and Poppins itself, so fontFamily: 'Inter, sans-serif'
renders in Inter; a font only your page loads cannot reach the frame and falls back down
your stack.
Wallets and 3-D Secure#
Apple Pay and Google Pay mount on the merchant page, directly above the card iframe. Not inside it. Apple's domain association and the loopback exemption are checked against the origin the sheet runs on, so a wallet mounted inside our frame would be checked against our origin instead of your registered domain. The PAN stays in the iframe; a wallet carries a cryptogram.
Google Pay has one shape that does not: PAN_ONLY, a card saved in the buyer's Google
Account rather than tokenised on an Android device (the usual case in desktop browsers and
on iOS). It returns the card itself with no cryptogram. It is charged as an ordinary
card rather than as a wallet — so it is scored like one and can be stepped up to 3DS,
which a wallet cannot. Nothing changes on your side; the button simply shows for those
buyers too, where it used to be hidden from them entirely.
There is no separate mount call. The sheet total is loaded from the intent. onCancel
fires when the buyer dismisses a sheet. Pass wallets: 'never' if you already mount
your own. The buttons' theme, wording and height are walletButtons (Apple and Google
draw them, so appearance does not reach them).
A wallet payment answers on the same callbacks, in the same shapes, as the card form:
onSuccess receives the same result (receipt fields and card_save included), and a
decline reaches onError as card_declined with declineCode and the same buyer
sentence. The sheet stays open until the charge answers; on a decline it is failed with
that same sentence.
The sheet asks the buyer for an email, name, phone and billing address, and the element
sends what the wallet returned with the payment, so a wallet buyer gets a receipt and an
address for AVS. Values you pass in defaultValues.billingDetails win over the wallet's.
Subscriptions paid by wallet#
When the payment starts a subscription, say so with recurring:
wp.mountPaymentElement('#payment', {
clientSecret, // the intent names a `customer`
recurring: {
description: 'Pro membership',
billingAgreement: 'You will be charged $9.99 every month until you cancel.',
managementUrl: 'https://shop.example/account/subscriptions',
regularBilling: { label: 'Pro', amount: 999, intervalUnit: 'month' },
// trialBilling: { label: 'First week free', amount: 0 },
},
onSuccess: (r) => { /* r.card_save says whether a renewal method was kept */ },
});
| Field | Required | Default | Meaning |
|---|---|---|---|
description | Yes | — | What the buyer subscribes to, shown on the Apple Pay sheet |
billingAgreement | Yes | — | The terms shown on the sheet before the buyer authorizes |
managementUrl | Yes | — | An https page where the buyer manages or cancels |
regularBilling.label / .amount | Yes | — | The repeating charge; amount in minor units, above zero |
regularBilling.intervalUnit | No | month | day, week, month, year |
regularBilling.intervalCount | No | 1 | Every N units |
regularBilling.startDate | No | now | When the repeating charge starts (ISO-8601 or Date) |
trialBilling | No | — | { label, amount, startDate? }, an introductory period; amount may be 0 |
It does two things, both on the wallet buttons only:
- The Apple Pay sheet opens as a recurring payment request. The buyer sees the description, the agreement, the cadence and the link, and Apple can issue a token that belongs to your relationship with the buyer rather than to their phone.
- A wallet payment asks to save its payment method, as a ticked "save this card" does
on the card form, and
onSuccessreceivescard_save. The subscription is the consent, so state its terms on your page. The intent needs acustomer, orcard_saveisskipped/customer_missing. - Google Pay offers only the cards it can hand back as the card itself (
PAN_ONLY). A device token cannot be saved, so a subscription paid with one would end at its first payment.
What card_save says today, by wallet:
| The buyer paid with | card_save | For renewals |
|---|---|---|
Google Pay (with recurring, always the card itself, PAN_ONLY) | saved, with a pm_… | Charge that pm_… like any saved card |
| Apple Pay | skipped / card_not_saveable | Collect a card for renewals |
An Apple Pay token cannot be saved yet: the gateway has to accept wallet tokens into its
vault first. To keep Apple Pay off a subscription, use the express element with
paymentMethods: { applePay: 'never' } (mountExpressCheckout). The sheet still shows the subscription terms. The amount the buyer
authorizes now is still the intent's total; regularBilling describes what follows.
A malformed recurring (a missing field, an http link, a fractional amount) reaches
onError and the Apple Pay button is not drawn. Mount-only. The card form is unchanged:
it keeps following fields.savePaymentMethod.
Wallet config may be none#
Do not assume Apple Pay or Google Pay are on. The SDK reads GET /v1/wallet-config (scope
none). Both flags false is the default, not an error, no buttons mount:
{
"object": "wallet_config",
"mode": "test",
"provider": "NONE",
"applePay": false,
"googlePay": false,
"evervaultMerchantId": ""
}
Mount a button only on a literal true. Treat the flags as fail-closed.
Dashboard (in-repo today). Merchant Dashboard → Settings → Checkout Settings → Digital Wallets. Switch Apple Pay / Google Pay on and save.
- Test mode: provision a sandbox wallet with Create test wallet on the same page, then flip the switches. Google Pay needs only that record; Apple Pay also needs a registered domain.
- Live mode: the switches stay disabled until support provisions a live wallet merchant record. Contact support; there is no self-serve live provisioner.
Until wallet-config reports a wallet on, the element is card-only. That is expected.
CSP only when a wallet is actually on (scripts load on your page, not in the iframe):
script-src https://applepay.cdn-apple.com https://pay.google.com
frame-src https://pay.google.com
Miss them and the buttons silently do not render. Safari blocks the Apple script without a console error, check CSP before anything else.
3-D Secure and return_url#
When a charge is stepped up, the element drives 3-D Secure itself: it mounts the challenge once, inside the element's own frame (the frame grows to fit it), then resubmits automatically. If the issuer authenticates without a challenge, no challenge is shown; the overlay says the card is being verified until the outcome arrives. There is nothing to wire. Driving 3DS yourself (native app or server-driven flow) is 3-D Secure.
Paygeyt has no return_url field on PaymentIntents or on mountPaymentElement. Stripe
uses return_url for a full-page redirect after 3DS; here the element stays on the
checkout page and the required onSuccess callback is the return path. Do not send
return_url.
The 3-D Secure overlay#
While 3-D Secure runs for a confirm from the card form, a payment or a card saved with a
setup intent (the bank collecting device data, a frictionless authentication, a challenge,
and the confirm after it), the element
covers your whole page: a dimmed layer with a spinner and "Verifying your card…"
("Verificando tu tarjeta…" for a Spanish locale). It is on by default and has no option,
as Stripe draws its own 3-D Secure UI over the merchant's page. A payment with no 3-D Secure
shows no overlay.
- The challenge sits above it. The bank's screen opens inside the element's frame; the overlay leaves the frame's area uncovered while it is open, and scrolls the frame into view if it is off screen. Nothing of yours is lifted or restacked.
- Your page cannot be used underneath. Clicks are blocked and focus is kept on the overlay
(on the frame while the challenge is open), so your own "Place order" button cannot be
pressed twice.
element.submit()during a payment is ignored, as it always was. - Escape dismisses nothing, the same as inside the bank's screen; the challenge's own
control is the way out. The key does not reach your page's handlers while the overlay is up,
and
onEscapeis not called, so a dialog of yours that closes on Escape stays open. - It comes down with the outcome: success, decline, error, a failed or abandoned
challenge. It is gone before
onSuccessoronErrorruns, so whatever you show there is not drawn under it, and focus goes back where it was.element.destroy()removes it too. - Nothing moves. It is a fixed layer, the page is not scroll-locked and nothing is injected into your stylesheet, so it needs no CSP change.
- It follows
appearance: the card's background, text colour, font and corner radius, and the spinner'scolorPrimary. The dimmed layer is the same on every page. The spinner stands still for a buyer who asked for reduced motion.
If your page shows its own busy state while a payment runs, keep it: the overlay is drawn
above everything else on the page (z-index: 2147483647).
Errors worth recognising#
| Code | Cause |
|---|---|
card_declined | Arrives on onError (type: 'card_error'), with declineCode when the decline has one. The element stays mounted; let the buyer retry, except after duplicate_transaction. |
three_ds_failed / three_ds_cancelled | Arrives on onError (type: 'card_error'): 3-D Secure did not authenticate the card, or the buyer closed the challenge. Nothing was charged and the element stays mounted. message is the sentence the buyer read, in the element's locale: "Your bank could not verify this card. Try again or use another card." or "Card verification was cancelled. You have not been charged." |
amount_changed | Apple Pay / Google Pay only: the intent's total changed after the sheet was drawn. Nothing was charged; the buttons redraw with the new total. |
request_timeout | The confirm and the read after it both failed to answer. The outcome is unknown: check the intent before letting the buyer pay again. |
element_load_failed | On onLoadError, not onError: the form never loaded. Check the key's allowed domains and your CSP. |
parameter_unknown | An amount or currency reached confirm. The intent fixed those. |
secret_key_in_browser | An sk_ reached the browser. Use pk_ plus a client secret. |
resource_missing | No such intent for this key's merchant and mode. |
container_not_found | The selector you passed matched no element. |
The full table is in Errors; the tokenization codes are in Error codes.
Test cards and how to force a decline or a 3DS challenge are in Testing.
