Skip to main content
The inline SDK is DuroCheckout.open() — a drop-in script that opens a payment sheet over the merchant’s own page, in an iframe, so card data never touches the merchant’s DOM. Mechanically it’s the hosted checkout flow rendered as a popup. What makes it different is the identity bar.

The popup flow

Integrate in six steps

The sheet renders the same flow as hosted checkout, in a popup over your page. You still create the session server-side with your secret key, then hand the browser only the session token — so the amount can never be tampered with client-side.
1

Add the SDK

Drop the script onto the page with your pay button. It exposes window.DuroCheckout.
<script src="https://js.useduro.com/inline.js"></script>
2

Create a session (server)

Identical to hosted checkout — your server calls POST /v1/checkout/sessions and returns just the token and id to the page. The secret key stays on the server.
// POST /api/pay  (your server)
app.post("/api/pay", async (req, res) => {
  const r = await fetch("https://api.useduro.com/v1/checkout/sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DURO_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      planId: req.body.planId,
      customerEmail: req.user.email,
      metadata: { orderId: req.body.orderId },
    }),
  });
  const { data } = await r.json();
  res.json({ token: data.token, id: data.id });
});
3

Open the sheet (client)

Fetch a token from your server, then call DuroCheckout.open() with your publishable key. Card entry happens inside the iframe — it never touches your DOM.
document.getElementById("pay").addEventListener("click", async () => {
  const { token } = await fetch("/api/pay", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ planId: "plan_01HFZ8Q…", orderId: "ord_1234" }),
  }).then((r) => r.json());

  DuroCheckout.open({
    key: "pk_test_•••",          // publishable key — safe in the browser
    token,                        // the session your server just created
    onSuccess: (reference) => {
      // soft confirmation — verify on your server before granting access
      window.location.href = `/checkout/confirming?ref=${reference}`;
    },
    onClose: () => console.log("sheet dismissed"),
    onError: (err) => console.error(err),
  });
});
4

Verify on success (server)

onSuccess fires in the browser, so confirm it server-side with the session id before acting. Source of truth, every time.
// GET /checkout/confirming → your server
async function isCompleted(sessionId: string) {
  const res = await fetch(
    `https://api.useduro.com/v1/checkout/sessions/${sessionId}`,
    { headers: { Authorization: `Bearer ${process.env.DURO_SECRET_KEY}` } },
  );
  const { data } = await res.json();
  return data.status === "completed" ? data : null;
}
5

Receive the webhook

The popup can be closed before onSuccess ever runs — so the webhook is your reliable trigger. Subscribe subscription_payment_success in Developers → Webhooks and verify the signature against the raw body. (Full handler and signature check in Webhook Events.)
app.post("/webhooks/duro", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.header("Duro-Signature"), process.env.DURO_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(raw);
  res.sendStatus(200);
  if (event.type === "subscription_payment_success") fulfil(event.data.subscriptionId);
});
6

Give value

Both paths — the onSuccess callback and the webhook — funnel into one idempotent fulfil() that verifies, then provisions exactly once.
async function fulfil(subscriptionId: string) {
  const order = await orders.bySubscription(subscriptionId);
  if (!order || order.fulfilled) return;          // idempotent
  const paid = await isCompleted(order.checkoutId); // verify
  if (!paid) return;
  await entitlements.grant(order.customerId, paid.planId);
  await orders.markFulfilled(order.id);
}
onSuccess is a convenience, not a guarantee — wire the webhook and the verify call too. The browser can vanish mid-payment; the server-side pair is what actually grants access.

The “save to my phone” moment is the whole strategy

When a guest pays and ticks save to my phone, the sheet:
  1. Sends a one-time code to their phone over the WhatsApp Cloud API.
  2. Verifies the code.
  3. Creates (or finds) the customer’s universal identity keyed to that phone.
  4. Links the tokenised payment method to the identity — not to the merchant.
The next time that human checks out anywhere on Duro, the identity bar recognises the phone and offers their saved card in one tap. That’s the network effect: the saved wallet belongs to the person. See the identity chapter →

Rails, inline

The same rail set as hosted checkout — card, direct debit, one-time bank transfer, USSD, virtual account — and the same instant rail-switch on failure (“Card didn’t go through — pay with transfer?”). For transfers, the sheet polls for confirmation; the merchant page just waits on the onSuccess callback.

Building blocks the frontend needs

The SDK is an iframe + a thin host script. The pieces:

The sheet

Overlay + responsive sheet (bottom-sheet on mobile, centred card on desktop), themed from the merchant’s appearance tokens.

OTP input

Six-box code field with paste support, a resend timer, and an “open WhatsApp” deep link.

Saved-methods carousel

The identity’s cards/mandates after OTP, with last-4 and brand — never the raw token.

Callback bridge

postMessage to the host page: onSuccess, onClose, onError.
Next: the universal identity that powers all of this.