Embedded checkout is our checkout inside your own page: the same checkout as the hosted page, with the same methods and the same rules for trying again, switching and cancelling, shown in a frame in an element of yours (inline) or in a dialog over your page (a modal). Your customer stays on your site, card included, unless their bank asks them to confirm a card payment.
- You have a publishable key and have registered your page's origin, once, in the Merchant Dashboard.
- Your server creates the payment intent with
checkout_modeembedded, and gives its client token to your page. - Your page loads our library and mounts embedded checkout with your publishable key and that client token.
- The customer pays inside it, and your page is told when they are done or have cancelled.
- Your server checks how the payment went, over the API or by webhook, before it fulfils the order.
1. Get a publishable key and register your origin
Section titled “1. Get a publishable key and register your origin”Your account also has two publishable keys, pk_test_... and pk_live_..., under Developers → API keys. A publishable key is for embedded checkout, the checkout you show inside your own page: it is meant to be in that page, so it is safe to show. It opens checkout only, and only together with the checkout's own client token. On any /v1 call it is refused like any invalid token, with authentication_failure, and at /oauth/token it is not a client ID or a secret.
A request with a publishable key is accepted only from your registered origins, which you add on the same page, one list for test mode and one for live. An origin is a scheme and a host, and a port if it has one: https://shop.example or http://localhost:3000. It has no path and no wildcard, and a live origin must use https. It matches exactly: https://www.shop.example is another origin, as is the same host on another port. A request with no Origin header is refused. A test key works only from your test origins, and a live key only from your live ones.
Rolling a publishable key replaces it at once, with no overlap: the old key is refused from the next request. Put the new key in your pages when you roll it. Adding, removing and rolling are recorded in your account's audit log.
Add the origin of the page that shows embedded checkout: to the test list for test intents, to the live list for live ones. It must match exactly, scheme, host and port: https://shop.example.com does not cover https://www.shop.example.com. Your test publishable key mounts only test intents, and only from your test origins; your live key only live intents, from your live origins.
2. Create the intent on your server
Section titled “2. Create the intent on your server”Create it as for hosted checkout, without a method and without confirm, and with checkout_mode embedded:
curl https://api.example.com/v1/payment_intents \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: order-1042-1" \ -H "Content-Type: application/json" \ -d '{ "amount": 10000, "currency": "php", "description": "Order 1042", "checkout_mode": "embedded", "return_url": "https://shop.example.com/orders/1042/paid", "customer_email": "maria@example.com" }'The intent is requires_action, and its next_action is mount_embedded_checkout:
{ "id": "pi_01j9x2m8k4q7r5t3v6w8y0z2a4", "object": "payment_intent", "status": "requires_action", "next_action": { "type": "mount_embedded_checkout", "mount_embedded_checkout": { "client_token": "ct_6QYI9milpE_Q7-uM7wvlj7fwyKooT2868Dm45Wwigcs", "expires_at": 1790413200 } }, ...}checkout_modeishostedwhen you leave it out. It is taken only on a create with no payment method and withoutconfirm, since the customer chooses the method at checkout; anywhere else, or with any other value, it is refused withvalidation_failureandparamcheckout_mode.return_urlis where the customer comes back to after their bank's step for a card (see Card). Card is offered only with one: give one if card may be chosen.cancel_urlis refused withparamcancel_url: a customer who cancels stays on your page, and your page is told byonCancel.customer_emailfills in the optional receipt field, as on the hosted page.
expires_at is when the checkout session ends, 30 minutes by default, as for hosted checkout. Reading the intent gives the same client token while the customer has not started a payment. Once they have, next_action is that payment's own step, such as qrph_display_qr_code, and once the session is over, null. So keep the client token with your order, to mount embedded checkout again. Webhooks never carry it.
The client token is meant for your page, but it is this one payment's checkout: give it only to the customer paying. It opens nothing on its own, only together with your publishable key, from one of your registered origins.
3. Load the library
Section titled “3. Load the library”In the page that shows embedded checkout, load the library with a script element whose src is on your platform's checkout host, shown here as checkout.example.com:
https://checkout.example.com/embedded-checkout.jsLoad it from there, not from a copy of your own: it shows embedded checkout from the host it was loaded from. It adds one thing to the page, EmbeddedCheckout. Call it with your publishable key:
const checkout = EmbeddedCheckout("pk_test_...");Then show embedded checkout inline or in a modal. Either way, give it the client token, and the callbacks your page wants.
4a. Inline, in your page
Section titled “4a. Inline, in your page”With an element for it in your page, such as <div id="checkout"></div>:
const checkout = EmbeddedCheckout("pk_test_...");const mounted = checkout.mount("#checkout", { clientToken: "ct_...", // next_action.mount_embedded_checkout.client_token onComplete: ({ payment_intent }) => { window.location.assign("/orders/1042/thanks"); }, onCancel: ({ payment_intent }) => { window.location.assign("/cart"); },});mount takes an element, or a selector for one, and fills it with embedded checkout: as wide as the element, and as tall as what it shows. title names the frame for screen readers, Checkout if you give none.
mounted.unmount() removes it from the page. That does not cancel the payment: mount again with the same client token, and embedded checkout opens where the payment stands. So does mounting again after a reload.
4b. In a modal
Section titled “4b. In a modal”const checkout = EmbeddedCheckout("pk_test_...");
document.querySelector("#pay").addEventListener("click", () => { const dialog = checkout.open({ clientToken: "ct_...", onComplete: ({ payment_intent }) => { dialog.close(); window.location.assign("/orders/1042/thanks"); }, onCancel: ({ payment_intent }) => dialog.close(), onClose: () => { // The customer closed it, or you did. The payment is not cancelled. }, });});open shows embedded checkout in a dialog over your page, centred, or filling the screen on one 520 pixels wide or less. It is an accessible dialog: marked as modal, it keeps the keyboard's focus inside it, closes with Escape or its close button, keeps your page from scrolling behind it, and gives focus back to where it was when it closes. title names the dialog for screen readers, and closeLabel its close button: Checkout and Close if you give none, so pass words in your page's language.
Closing the dialog does not cancel the payment. Open it again with the same client token, and it shows the payment where it stands. The dialog stays open when the customer is done or cancels: close it with dialog.close(), which also calls onClose, when your page is ready to move on.
Callbacks
Section titled “Callbacks”| Callback | When | Given |
|---|---|---|
onComplete | The customer is done: they paid inside embedded checkout, or were given a cash reference to pay later. Once per mount. Not for a card whose bank's step took the customer off your page. | { payment_intent }, the intent's id |
onCancel | The customer cancelled: the intent is canceled, with cancellation_reason requested_by_customer, and you get payment_intent.canceled. | { payment_intent } |
onClose | Modal only: the dialog closed, by the customer or by dialog.close(). | nothing |
Every callback is optional. A callback carries only the payment intent's id, never a status or an amount, and it is not proof of payment: anything on your page can be changed by whoever is looking at it. For cash, onComplete comes once the customer has their reference; the intent stays requires_action until the cash arrives.
Card: typed inside embedded checkout
Section titled “Card: typed inside embedded checkout”The customer types their card inside embedded checkout, on your page: the card number, expiry date and security code go into secure card fields within embedded checkout's own frame. Nothing of the card reaches your page, your servers or the API: your page is told, as for any method, only the payment intent's id.
- On your registered origins. Card is offered on the page a registered origin of yours mounts embedded checkout from, once the platform has confirmed that its card processor allows that origin to show its card fields. You ask for nothing: the processor allows every https origin today, so a live origin is offered card as soon as you register it, and if that ever changes, an origin it does not allow is offered QR Ph and cash alone. A checkout session is shown from the one origin that first mounted it; mounting it from another is refused.
- A
return_urlis required. The customer's bank may ask them to confirm a card payment, on its own page, which cannot be shown in a frame. Without areturn_urlnothing could bring them back to you from there, so embedded checkout offers QR Ph and cash alone. - Most cards complete inside embedded checkout. The payment succeeds in the frame, which shows it, and
onCompleteis called with the intent'sid. - When the bank asks to confirm, embedded checkout shows a link to the bank's step that the customer follows: your whole page goes there, in the same tab or webview, never in a popup. Afterwards our return page shows how it went and sends them on to the intent's
return_url, withpayment_intent=<id>andcheckout_session=<id>added and never a status.onCompleteis not called for that payment: your page was not there to be told. - A declined card, or one the bank did not confirm, fails that attempt with
last_payment_errorandpayment_intent.payment_failed. The customer may try another card or another method, inside embedded checkout, within the session's three attempts.
At your return_url, check the payment as in Check how it went. While its session is open, you can mount embedded checkout again with the same client token, so the customer can try again or another way.
Card data and your PCI DSS scope
Section titled “Card data and your PCI DSS scope”Embedding embedded checkout puts card entry inside your page's frame tree: your page is an ancestor of the frames the card is typed into, though the card never reaches your page.
Check how it went
Section titled “Check how it went”Before you fulfil the order, retrieve the intent on your server, GET /v1/payment_intents/{id} with the id your page was given, or wait for its webhook:
| Event | |
|---|---|
payment_intent.succeeded | Paid. For cash, once the cash was paid. |
payment_intent.processing | The customer paid and the outcome is not known yet. |
payment_intent.payment_failed | An attempt failed. The customer may still try again in the session. |
payment_intent.canceled | Cancelled: by the customer, with requested_by_customer, or by you. |
While the customer pays, the intent moves as it does with hosted checkout or over the API, and you get the same webhooks.
Your page
Section titled “Your page”- https in live. A live origin must be https. A test origin may be http, such as
http://localhost:3000. - Content Security Policy. If your page sends one, it must allow the checkout host for
script-src(the library),frame-src(embedded checkout's frame) andform-action(mounting it). For example:script-src 'self' https://checkout.example.com; frame-src https://checkout.example.com; form-action 'self' https://checkout.example.com. - Referrer Policy. Do not send
Referrer-Policy: no-referrerorsame-originon that page. With either, the browser hides your page's origin from the checkout host, and the mount is refused. The browser's default,strict-origin-when-cross-origin, is fine. - Nothing on the checkout host is read from your page across origins, so there is no CORS to set up, and embedded checkout needs no cookies.
In a mobile app
Section titled “In a mobile app”Embedded checkout works in an Android WebView and an iOS WKWebView, with no cookies and no popups. Load your page from its registered https origin, with loadUrl or load(URLRequest): not from a local file or an HTML string, which have no origin to register, so the mount is refused. On Android, enable JavaScript (setJavaScriptEnabled(true)) and leave setSupportMultipleWindows off, so the bank's step opens in the same webview. On iOS, keep JavaScript enabled, which WKWebView is by default. The card fields are frames inside embedded checkout's frame, which a webview shows as a browser does: there is nothing more to allow. The bank's step, and the way back to your return_url, navigate the webview itself; your app can watch for your return_url to know the customer is back, then check the payment on your server.
When embedded checkout does not load
Section titled “When embedded checkout does not load”A mount that is refused shows an empty frame, and says nothing of why, on purpose. Check that:
- the page's origin is registered exactly, for the intent's mode: scheme, host and port;
- the publishable key is your current one for the intent's mode,
pk_test_...for a test intent andpk_live_...for a live one, and not one you have since rolled; - the client token is this intent's, and its checkout session is not over: not expired, paid or cancelled. A cash reference not paid yet is shown again;
- the session was not mounted first from another of your origins: a checkout session is shown from the origin that first mounted it, and no other;
- the page is not sent with
Referrer-Policy: no-referrerorsame-origin, and its Content Security Policy allows the checkout host; - in a webview, the page was loaded from its https origin, not from a file or a string.
A key or a client token that is not even shaped like one, or an element that is not on the page, is refused before anything loads: EmbeddedCheckout and its mount and open throw a TypeError whose message starts EmbeddedCheckout: and says what to pass.