> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Hosted Checkout

> A branded, tokenised checkout you redirect to — session in, subscription out, no card data on your servers.

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

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant S as Merchant server
    participant API as public-api (/v1)
    participant Page as Hosted page (/checkout)
    participant C as Customer
    participant GW as Gateway

    S->>API: POST /v1/checkout/sessions { planId }
    API-->>S: { token: cs_test_…, url }
    S-->>C: redirect to url
    C->>Page: GET /checkout/{token}
    Page-->>C: branded page (plan, amount, rails)
    C->>Page: POST /checkout/{token}/pay { rail }
    Page->>GW: charge
    alt success
        GW-->>Page: succeeded
        Page->>API: create customer + subscription + paid invoice
        Page-->>C: success → successUrl
    else decline
        GW-->>Page: failed
        Page-->>C: choose another rail
    end
    C->>Page: GET /checkout/{token}/status (poll)
```

## 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.

<Steps>
  <Step title="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.

    ```bash theme={null}
    export DURO_SECRET_KEY=sk_test_•••••••••••••••••
    export DURO_WEBHOOK_SECRET=whsec_•••••••••••••••••
    ```
  </Step>

  <Step title="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.

    <CodeGroup>
      ```bash cURL theme={null}
      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" }
        }'
      ```

      ```ts Node (fetch) theme={null}
      async function createSession(orderId: string, planId: string, email: string) {
        const res = 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,
            customerEmail: email,
            successUrl: "https://acme.test/checkout/return",
            metadata: { orderId },
          }),
        });
        const { data } = await res.json();
        return data; // { id, token, url, status, amount, currency }
      }
      ```
    </CodeGroup>

    ```json Response theme={null}
    {
      "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"
      }
    }
    ```

    <Note>Amounts are integer **kobo** — `1500000` is ₦15,000. Store `data.id` against your order; it's what you verify with later.</Note>
  </Step>

  <Step title="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.

    ```ts theme={null}
    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);
    });
    ```
  </Step>

  <Step title="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.

    ```ts theme={null}
    app.get("/checkout/return", (req, res) => {
      const { reference } = req.query;            // cs_test_…
      res.render("confirming", { reference });    // page polls your own order status
    });
    ```
  </Step>

  <Step title="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.

    ```ts theme={null}
    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);
      }
    });
    ```
  </Step>

  <Step title="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.

    <CodeGroup>
      ```ts Verify theme={null}
      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;
      }
      ```

      ```ts Give value theme={null}
      async function fulfil(subscriptionId: string) {
        const order = await orders.bySubscription(subscriptionId);
        if (!order || order.fulfilled) return;       // idempotent
        const paid = await isCompleted(order.checkoutId);
        if (!paid) return;                           // not actually paid — ignore
        await entitlements.grant(order.customerId, paid.planId);
        await orders.markFulfilled(order.id);
      }
      ```
    </CodeGroup>
  </Step>
</Steps>

<Warning>
  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.
</Warning>

## 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.)

```mermaid theme={null}
flowchart LR
    TOK["cs_live_abc…"] --> PARSE["split on '_' → 'live'"]
    PARSE --> ONE["query live schema only"]
    BAD["garbage / cs_test_ in live"] --> NF["not found"]
```

## 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](/payments/inline-sdk), the customer portal, and the emails, so a merchant brands once. [See the appearance builder →](/platform/appearance)

Next: the [inline SDK](/payments/inline-sdk) — the same flow as a popup, plus saved-card identity.
