SUQODocs
User GuideAPI ReferenceSDKs

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.

Read When the callbacks don't fire before you build against 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 againstScript tag
Sandbox datahttps://test.suqo.ai/suqo-checkout.js
Live datahttps://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:

HowWhen 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:

AttributeMeaning
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-blockMarks 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 URL

configure({ 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

OptionTypeNotes
sessionIdstringThe checkout session id, e.g. cks_9f2c41a8. Alias: checkoutSessionId
elementIdstringThe container to mount into. configure() only. Alias: container
originstringWhere the checkout is served from. Overrides the script's own host
flow'redirect'Render a link to the hosted checkout instead of an iframe
flowLabelstringLabel for that link. mount() only — not accepted by configure()
onReady() => voidThe block is on screen, payable or not
onSuccess(params, message?) => voidExactly once per attempt
onFailure(status, params, message?) => voidExactly once per attempt. 'cancelled' | 'failed'
onUnavailable(reason) => voidThe session cannot be paid at all. Not a payment outcome
onIntent(url) => voidA 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.

ReasonMeans
not-foundno such session at this origin (often the wrong environment)
expiredthe session's window has passed
spentit has already been paid
no-customerit was created without a customer assigned
no-methodsthe seller has no payment method the buyer can use
load-failedthe 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.

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:

SurfaceWhat happensYou hear about it via
Desktop or mobile browserPayment opens in a popup, on top of your pageonSuccess / onFailure
In-app browser (Instagram, Facebook, LINE, some WebViews)The script navigates your top-level page to SUQO's hosted checkout, automaticallyreturn_url + webhook only
You opted out with data-suqo-flow="redirect"A link to SUQO's hosted checkoutreturn_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.

Fulfil orders from your webhook, not from 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-ancestors restriction 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.

On this page