Skip to main content
Hosted checkout is the no-code path to a paying subscriber. The merchant creates a session server-side, redirects the customer to a Duro-hosted page, and Duro handles identity, payment, tokenisation, and subscription creation. The merchant’s servers never touch card data.

The lifecycle of a session

Integrate in six steps

Redirect checkout is server-to-server plus one browser redirect. Your secret key (sk_…) never leaves your server; the customer only touches the Duro-hosted page.
1

Set your keys

Copy your keys from Developers → API keys. The prefix sets the mode — sk_test_… / pk_test_… for sandbox, sk_live_… / pk_live_… for live. Keep the secret key server-side only.
export DURO_SECRET_KEY=sk_test_•••••••••••••••••
export DURO_WEBHOOK_SECRET=whsec_•••••••••••••••••
2

Create a checkout session (server)

POST /v1/checkout/sessions with a planId (or a one-off amount in kobo). Duro returns the session id, a token, and the hosted url. Pass a successUrl to choose where the customer lands afterward, and a metadata object to carry your own order id all the way through to the webhook.
curl https://api.useduro.com/v1/checkout/sessions \
  -H "Authorization: Bearer $DURO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "planId": "plan_01HFZ8Q…",
    "customerEmail": "ada@example.com",
    "successUrl": "https://acme.test/checkout/return",
    "metadata": { "orderId": "ord_1234" }
  }'
Response
{
  "ok": true,
  "data": {
    "id": "chk_01HFZ8Q…",
    "token": "cs_test_9wT…",
    "url": "https://checkout.useduro.com/checkout/cs_test_9wT…",
    "status": "pending",
    "kind": "subscription",
    "amount": 1500000,
    "currency": "NGN"
  }
}
Amounts are integer kobo1500000 is ₦15,000. Store data.id against your order; it’s what you verify with later.
3

Redirect the customer

Send the browser to session.url. Duro handles identity, card capture, rail selection, and subscription creation — your servers never see card data.
app.post("/checkout/start", async (req, res) => {
  const session = await createSession(req.body.orderId, req.body.planId, req.user.email);
  await orders.attachCheckout(req.body.orderId, session.id);
  res.redirect(303, session.url);
});
4

Handle the return (callback)

When payment resolves, Duro returns the customer to your successUrl with the reference appended. Treat this as a UI hint only — render a “confirming…” screen, never grant access here. A browser can be closed, replayed, or spoofed; the webhook and the verify call are the real triggers.
app.get("/checkout/return", (req, res) => {
  const { reference } = req.query;            // cs_test_…
  res.render("confirming", { reference });    // page polls your own order status
});
5

Receive the webhook

Subscribe your endpoint in Developers → Webhooks and select subscription_payment_success (and subscription_created). Verify the signature against the raw body, dedupe on Duro-Event-Id, and return 200 fast — do the slow work afterward.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(raw: string, header: string, secret: string, toleranceSec = 300) {
  const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(p.t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${p.t}.${raw}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(p.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

app.post("/webhooks/duro", express.raw({ type: "application/json" }), async (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);                         // ack first, work after

  if (await seen(event.id)) return;            // dedupe on Duro-Event-Id
  await markSeen(event.id);
  if (event.type === "subscription_payment_success") {
    await fulfil(event.data.subscriptionId);
  }
});
6

Verify, then give value

Before provisioning, confirm the truth from Duro with the session id. Act only when status is completed. The same call backs both the callback and the webhook — so a dropped webhook or a closed browser never costs you a fulfilment, and a replay never double-grants.
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;
}
Grant access only after verify returns completed. The redirect callback is a hint, the webhook is the trigger, and the verify call is the proof — wire all three and a single dropped or replayed message never under- or over-delivers.

The token carries the mode

A checkout token is cs_<mode>_<random>. The public hydrate/pay/status endpoints are unauthenticated — they’re hit by a customer’s browser with no API key — so the mode can’t come from a key prefix. It comes from the token itself: the service parses cs_test_ vs cs_live_ and queries exactly one schema. (An earlier version scanned both schemas; deriving the mode from the prefix made it a single query and is documented as a perf fix.)

What a successful pay does, atomically

On a successful charge the service does the full subscriber setup in one flow — and reuses an existing customer instead of minting duplicates:
  1. Resolve the customer by email/phone (findByContact). If they’re blacklisted, the pay is rejected before the charge — no money moves for a banned customer.
  2. Charge the chosen rail.
  3. Create the subscription (active), compute its first period via BillingCycle.
  4. Create the invoice paid, record the PaymentAttempt.
  5. Emit subscription_created and subscription_payment_success — which fan out to the merchant’s webhooks.
  6. Mark the session completed, so a second pay is idempotent (it returns the completed result rather than charging again).

Branding comes from the appearance config

The hosted page renders with the merchant’s published appearance config — logo, colours, copy — pulled from the core schema’s StoreConfig. The same token set themes the inline SDK, the customer portal, and the emails, so a merchant brands once. See the appearance builder → Next: the inline SDK — the same flow as a popup, plus saved-card identity.