Usage guide
Integrate the SUQO Checkout.js script: environments, placement, outcomes, options, and troubleshooting.
A guide for merchants embedding the SUQO payment block on their own page, where they put a <div>.
What it is
One <script> tag and one <div>. The buyer pays inline, in your page's own layout — no modal, no page takeover.
Your backend creates a checkout session through SUQO's API and hands the buyer its id. The block collects the payment for that session — nothing else. There is no phone step and no billing form, because by the time it renders there is nothing left to ask: the session already knows who the buyer is and what they are buying. You keep your own product page, order summary and layout; the block only shows what is still undecided — which gateway, and consent.
Quick start
<script src="https://test.suqo.ai/suqo-checkout.js"></script>
<div id="my-payment-area" data-suqo-checkout-session-id="cks_9f2c41a8"></div>
<script>
SuqoCheckout.configure({
elementId: 'my-payment-area',
onSuccess: function (params, message) { /* send the buyer to your thank-you page */ },
onFailure: function (status, params, message) { /* tell the buyer */ },
})
</script>One id, and no buyer details anywhere on the page — the session already carries them, created server-side with your API key. Nothing about the customer touches the DOM, the URL, or anything a third-party script on your page could read.
The block mounts on load; nothing needs to call mount(). It also re-scans the page on DOM changes, so a block rendered later — an SPA route, a lazy section, a CMS widget — mounts with no extra step. configure() can run before or after the block mounts: callbacks attach whenever they are given, so script order never decides whether you hear about a payment.
onSuccess. On some surfaces it cannot fire at all, and fulfilment has to come from your webhook.There is nothing about the buyer here
Look at the options: a session id, an environment, some callbacks. No name, no email, no phone, no amount. The session carries all of it server-side, which keeps buyer details off your page entirely — there is no attribute here to put them in and no request from here that could carry them.
Choosing the environment
Sandbox and live are two separate deployments. Point the <script> at the one you are using — everything else resolves from it automatically:
| You are testing against | Script tag |
|---|---|
| Sandbox data | https://test.suqo.ai/suqo-checkout.js |
| Live data | https://app.suqo.ai/suqo-checkout.js |
Going live is that one-word change to the src. The script builds its frame URL from the host it was loaded from, so as long as your checkout session was created against the same environment, nothing else needs to change.
The session and the host must agree. A session created against test.suqo.ai and opened against app.suqo.ai is simply not found there, and you get onUnavailable('not-found').
To point at something else — a staging mirror, a proxy, a local dev server — set data-suqo-origin on the element, or origin in configure() or mount().
Choosing where it renders
Name your own element — a fixed id cannot be assumed free on your page. Most specific wins:
| How | When to use it |
|---|---|
SuqoCheckout.mount(elOrId, opts) | Explicit. Skips auto-discovery entirely. |
SuqoCheckout.configure({ elementId: 'my-div' }) or <script … data-suqo-element-id="my-div"> | The normal path. |
<div data-suqo-checkout-block> | Any element, and any number of them on one page. |
<div id="suqo-checkout-block"> | The zero-config fallback, if you name nothing. |
container is accepted as an alias for elementId.
Two blocks on one page is supported: each keeps its own session, its own height and its own callbacks.
Declarative attributes
Read off the container, so each block carries its own session:
| Attribute | Meaning |
|---|---|
data-suqo-checkout-session-id="cks_…" | The checkout session to pay. Canonical spelling. |
data-suqo-session-id="cks_…" | Short alias for the above. |
data-suqo-flow="redirect" | Skip the iframe; render a link to the hosted checkout instead. |
data-suqo-flow-label="…" | Label for that link. Default: "Continue to payment". |
data-suqo-checkout="https://…" | A full URL, used verbatim — an escape hatch for a non-standard path. |
data-suqo-origin="https://…" | Per-element base URL override. |
data-suqo-checkout-block | Marks the element as a container. |
There are no attributes for the buyer — there is nothing to tell the block about them, since the session already carries the customer.
Programmatic API
SuqoCheckout.mount('suqo-checkout-block', {
sessionId: 'cks_9f2c41a8', // or checkoutSessionId
flow: 'redirect', // optional — link instead of an iframe
flowLabel: 'Continue to payment', // optional label for that link
origin: 'https://test.suqo.ai', // optional per-mount override
onReady: function () {},
onSuccess: function (params, message) {}, // gateway params + the backend's message
onFailure: function (status, params, message) {}, // 'cancelled' | 'failed'
onUnavailable: function (reason) {}, // the session cannot be paid at all
onIntent: function (url) {}, // a bank-app deeplink
})
SuqoCheckout.unmount('suqo-checkout-block')
// elementId also as container, sessionId also as checkoutSessionId.
// flowLabel is not accepted here — set it per mount or with data-suqo-flow-label.
SuqoCheckout.configure({
origin, elementId, sessionId, flow, onReady, onSuccess, onFailure, onUnavailable, onIntent,
})
SuqoCheckout.origin // getter — the currently resolved base URLconfigure({ sessionId }) is a convenience for the single-block case, and it is consumed once: the first block with no id of its own takes it, then it is cleared. Put ids on the container itself if your page might carry more than one block — otherwise a block rendered later by an SPA route change would try to mount the same session again.
configure() sets defaults for auto-discovered blocks; per-element data-suqo-* attributes and per-mount() options always win over it. Mounting the same container twice tears the first frame down rather than stacking a second.
Options
| Option | Type | Notes |
|---|---|---|
sessionId | string | The checkout session id, e.g. cks_9f2c41a8. Alias: checkoutSessionId |
elementId | string | The container to mount into. configure() only. Alias: container |
origin | string | Where the checkout is served from. Overrides the script's own host |
flow | 'redirect' | Render a link to the hosted checkout instead of an iframe |
flowLabel | string | Label for that link. mount() only — not accepted by configure() |
onReady | () => void | The block is on screen, payable or not |
onSuccess | (params, message?) => void | Exactly once per attempt |
onFailure | (status, params, message?) => void | Exactly once per attempt. 'cancelled' | 'failed' |
onUnavailable | (reason) => void | The session cannot be paid at all. Not a payment outcome |
onIntent | (url) => void | A bank/wallet deeplink. The script never navigates on it |
params is the gateway's return data exactly as the gateway sent it, with no renaming or filtering. Every gateway names its transaction id differently (data, pidx, TXNID, MerchantTxnId, …) — reconcile against whatever field your own backend already recorded.
message is separate from params because it comes from SUQO's backend, not the gateway — mixing the two would break that reconciliation. It is undefined when the backend said nothing; on a failure, it is often the only place the reason is stated.
How a payment settles
When a payment settles, the block calls onSuccess or onFailure and shows a short line of its own — "Payment successful.", "The payment did not go through.", "Payment was cancelled before it completed." — or the gateway's own wording, when it sent one.
That line is a statement of fact, not a next step: the block sits inside your layout and has no idea what comes after — an order page, a receipt, your own error screen. It never offers the buyer a second attempt. One block, one attempt, one outcome. A buyer who wants to try again does so from your own page, with a new checkout session — re-mount() the container to start one.
The payment itself is unaffected by any of this: it is already settled, and your backend gets the webhook regardless of what your page does.
cancelled is not failed
onFailure also fires when the buyer closes the payment window without paying — status 'cancelled', with empty params, since there was never a transaction to report. Check status rather than treating every non-success the same way:
'cancelled'— the buyer backed out. Offer the basket again.'failed'— a payment was attempted and rejected. Worth looking into; nothing was charged.
onUnavailable is not a failure
It fires when the session itself cannot be paid. No payment was attempted, so onFailure does not also fire.
| Reason | Means |
|---|---|
not-found | no such session at this origin (often the wrong environment) |
expired | the session's window has passed |
spent | it has already been paid |
no-customer | it was created without a customer assigned |
no-methods | the seller has no payment method the buyer can use |
load-failed | the block could not load its own data |
It is usually a sign your integration created the session wrong — an expired or already-used id, most commonly.
onIntent — a bank-app deeplink, handed to you verbatim
Some gateways hand off to a bank or wallet app through a non-http(s) deeplink (intent://…, fonepayApp://…). The frame cannot navigate your page to one itself — it is sandboxed so it cannot move your top-level page at all — so it only asks. onIntent(url) fires with the raw deeplink, and the script never navigates anywhere on it. What you do next — typically location.href = url, in your own page — is your own code's call.
javascript:, data:, file: and blob: are refused before they would ever reach your handler. Everything else passes through exactly as the gateway sent it, the same as params.
No onIntent configured? Nothing breaks — the console gets a warning and the buyer is left exactly where they would be without the feature, not stuck on a dead spinner.
When the callbacks don't fire
Most payments complete in a popup, on top of your own page — the buyer never leaves it, and the outcome arrives through onSuccess / onFailure. But on some surfaces that popup cannot open, and your page is gone before the payment settles:
| Surface | What happens | You hear about it via |
|---|---|---|
| Desktop or mobile browser | Payment opens in a popup, on top of your page | onSuccess / onFailure |
| In-app browser (Instagram, Facebook, LINE, some WebViews) | The script navigates your top-level page to SUQO's hosted checkout, automatically | return_url + webhook only |
You opted out with data-suqo-flow="redirect" | A link to SUQO's hosted checkout | return_url + webhook only |
The navigation in the in-app browser row is done by the script, not the frame — the sandboxed frame cannot move your page, so it asks and the script does it, once the target is checked to be an http(s) URL on SUQO's checkout origin. It happens without the buyer doing anything, and it replaces your document: anything held only in memory — an unsaved cart, SPA state, form input — is lost. Persist it before the buyer reaches the block, and use return_url to restore it. No callback fires on this path.
You cannot tell in advance which row a given buyer lands on — the same buyer gets a different path from an Instagram link than from their desktop.
onSuccess. If your page might ever be opened inside an app, the checkout session you create must carry a return_url — it is the only way that buyer gets back to you. Treat the callbacks as a desktop-case convenience: a merchant who ships orders only from onSuccess has no fulfilment path on the in-app-browser and redirect surfaces.Opting out of the embed
If you already know your page is rendered inside your own WebView and would rather not show a payment block the buyer cannot complete there, say so — the script renders a link to the hosted checkout instead of an iframe:
<div
data-suqo-checkout-block
data-suqo-checkout-session-id="cks_9f2c41a8"
data-suqo-flow="redirect"
data-suqo-flow-label="Continue to payment"
></div>This renders a link, not an automatic navigation — an automatic redirect on mount would fire again from your own router on every back-navigation and trap the buyer.
What the frame looks like
<iframe
title="SUQO — Secure payment"
style="width:100%; height:<reported>; border:0; overflow:hidden; background:transparent"
></iframe>Transparent, and sized to its own content — there is no fixed height and no internal scrollbar. The frame reports its height and the block applies it, so opening the bank picker inside the block grows it in your page's flow rather than scrolling inside a box.
The buyer pays in a separate window, not inside the frame or your page — your page does not navigate to it. That window closes itself once the result is known, on failure as well as success, so the buyer is not left to find and close a leftover tab.
If the block never finishes loading, it shows an explanatory panel with an "open in a new tab" link rather than hanging on a spinner. On a very slow connection this can take up to about 50 seconds to appear, since it waits through a few separate stages (the document loading, the script running, the block mounting) before giving up.
Known limits
- Third-party cookies. Anything the frame stores runs in a third-party browser context, so Safari — and Chrome, after third-party cookie phase-out — can restrict it. This is a platform constraint, not something specific to this integration.
- Any site can frame the checkout today. There is no
frame-ancestorsrestriction yet, so nothing stops a different site from embedding the same block. This does not expose buyer data — the session is scoped, single-use, and carries no personal data — but it is worth knowing if you were assuming otherwise.
Troubleshooting
The block never appears / stays blank. Open the console. A console.warn names the likely cause when a load deadline expires. Common causes: the session id is wrong, expired, or already used; the script's src points at a different environment (sandbox vs. live) than the one the session was created in; or the page has a Content-Security-Policy that blocks framing.
SuqoCheckout is not defined. The <script> tag did not load — check the src URL and that nothing (an ad blocker, a CSP script-src) is blocking it.
It worked in testing but a specific buyer says nothing happened. Likely an in-app browser (see When the callbacks don't fire) with no return_url set on the session — check the webhook log for that buyer's session instead of relying on the callback having fired.
Two blocks on one page are interfering with each other. Give each container its own data-suqo-checkout-session-id rather than relying on configure({ sessionId }), which only the first matching block consumes.