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

# Hosted Checkout

> What to write on the backend, the frontend, and the webhook handler.

This page assumes you have picked an integration path. If not, start with [Choose an Integration](/choose-integration).

The examples use Hosted Checkout with Node.js and Express. The structure is the same in any language or framework.

<Note>
  The order model, Session creation, and webhook handling on this page are **shared by every kind of payment** — one-time, subscription, and discounted payments all build on them.

  The examples themselves are one-time payments. Recurring billing is in [Subscriptions](/subscriptions), and discounts are in [Discounts and promotion codes](/promotions).
</Note>

## The three endpoints you need

| Endpoint                    | Does                                                                            | Does not                                |
| --------------------------- | ------------------------------------------------------------------------------- | --------------------------------------- |
| `POST /api/checkout/create` | Validates the item and amount, creates your order, then creates a Clink Session | Let the frontend decide the price       |
| `GET /api/orders/:id`       | Lets the return page check order status                                         | Pass Clink's raw response through       |
| `POST /api/webhooks/clink`  | Receives events, verifies signatures, updates orders, triggers fulfillment      | Trust the request body before verifying |

## What to store on your own order

At minimum these fields. Without them you cannot debug anything later.

```sql theme={null}
merchant_order_id      -- your order number
customer_id            -- the user in your system
product_snapshot       -- name, unit price, quantity, as of purchase time
original_amount
original_currency
clink_session_id       -- sessionId returned by Clink
clink_order_id         -- orderId produced by the payment
payment_status         -- your own payment status
fulfillment_status     -- kept separate from payment status
created_at
updated_at
```

Snapshot the product at purchase time. After a price change you still need to know what this customer paid.

Payment status and fulfillment status are separate columns because they go out of sync: money arrives, fulfillment fails, and you need to be able to find those orders and retry.

## 1. Create the order and the Session

<CodeGroup>
  ```javascript Node.js (direct API) theme={null}
  const CLINK_API = 'https://uat-api.clinkbill.com/api';

  async function clinkRequest(path, body) {
    const res = await fetch(`${CLINK_API}${path}`, {
      method: 'POST',
      headers: {
        'X-API-Key': process.env.CLINK_SECRET_KEY,
        'X-Timestamp': String(Date.now()),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    const json = await res.json();
    if (json.code !== 200) {
      throw new Error(`Clink ${path} failed: ${json.code} ${json.msg}`);
    }
    return json.data;
  }

  app.post('/api/checkout/create', async (req, res) => {
    const { productId, quantity } = req.body;
    const user = req.user;

    // Price comes from your database. Never from the request body.
    const product = await db.products.findById(productId);

    // Prices are stored as integer minor units and converted once
    const unitAmount = product.unitAmountMinor / 100;              // 1999 -> 19.99
    const amount = (product.unitAmountMinor * quantity) / 100;

    const order = await db.orders.create({
      customerId: user.id,
      productSnapshot: { name: product.name, unitAmount, quantity },
      originalAmount: amount,
      originalCurrency: 'USD',
      paymentStatus: 'created',
      fulfillmentStatus: 'none',
    });

    const session = await clinkRequest('/checkout/session', {
      customerEmail: user.email,
      referenceCustomerId: user.id,
      originalAmount: amount,
      originalCurrency: 'USD',
      merchantReferenceId: order.id,
      uiMode: 'hostedPage',
      priceDataList: [
        { name: product.name, quantity, unitAmount, currency: 'USD' },
      ],
      successUrl: `https://your-site.com/pay/result?orderId=${order.id}`,
      cancelUrl: `https://your-site.com/pay/cancel?orderId=${order.id}`,
    });

    await db.orders.update(order.id, {
      clinkSessionId: session.sessionId,
      paymentStatus: 'pending',
    });

    res.json({ merchantOrderId: order.id, checkoutUrl: session.url });
  });
  ```

  ```javascript Node.js (server SDK) theme={null}
  import { ClinkPayClient } from '@clink-ai/clink-typescript-sdk';

  const client = new ClinkPayClient({
    apiKey: process.env.CLINK_SECRET_KEY,
    env: 'sandbox',
  });

  app.post('/api/checkout/create', async (req, res) => {
    const { productId, quantity } = req.body;
    const user = req.user;

    const product = await db.products.findById(productId);

    const unitAmount = product.unitAmountMinor / 100;
    const amount = (product.unitAmountMinor * quantity) / 100;

    const order = await db.orders.create({
      customerId: user.id,
      productSnapshot: { name: product.name, unitAmount, quantity },
      originalAmount: amount,
      originalCurrency: 'USD',
      paymentStatus: 'created',
      fulfillmentStatus: 'none',
    });

    const session = await client.createCheckoutSession({
      customerEmail: user.email,
      referenceCustomerId: user.id,
      originalAmount: amount,
      originalCurrency: 'USD',
      merchantReferenceId: order.id,
      uiMode: 'hostedPage',
      priceDataList: [
        { name: product.name, quantity, unitAmount, currency: 'USD' },
      ],
      successUrl: `https://your-site.com/pay/result?orderId=${order.id}`,
      cancelUrl: `https://your-site.com/pay/cancel?orderId=${order.id}`,
    });

    await db.orders.update(order.id, {
      clinkSessionId: session.sessionId,
      paymentStatus: 'pending',
    });

    res.json({ merchantOrderId: order.id, checkoutUrl: session.url });
  });
  ```
</CodeGroup>

<Note>
  The server can also use [`@clink-ai/clink-typescript-sdk`](/api-reference/SDK), which handles the auth headers and ships type definitions. The direct-API version above is here so you can see where each field goes.
</Note>

Things that bite people:

* **Read the price from your database.** Never trust an amount sent by the frontend, or a customer can edit the request and buy a membership for one dollar.
* **Amounts are in the major currency unit.** USD 19.99 is `19.99`. Sending `1999` charges USD 1999 — a factor of 100. JPY, KRW, and IDR have no decimals and take integers only.
* **Do not multiply amounts as floats.** In JavaScript `0.1 * 3` is `0.30000000000000004`, and Clink checks that the line items sum exactly to the total, so that drift is rejected. Store prices as integer minor units, multiply, and convert once; use a decimal library for anything more involved.
* **`merchantReferenceId` is your order number.** Clink does not use it for idempotency — the same value twice gives you two Sessions. Preventing duplicate orders is your job.
* **`referenceCustomerId` is your user ID.** Send it and Clink links later Sessions for the same person to the same Clink customer.

## 2. The return page

The customer comes back to `successUrl`. This page does one thing: read your own order status and show it.

```javascript theme={null}
const orderId = new URLSearchParams(location.search).get('orderId');
const order = await fetch(`/api/orders/${orderId}`).then((r) => r.json());

if (order.paymentStatus === 'paid') {
  showSuccess();
} else if (order.paymentStatus === 'pending') {
  showPending();   // "Confirming payment" — check again in a few seconds
} else {
  showFailed();
}
```

The webhook may not have arrived yet when the customer returns, so the order can still be `pending`. Show "confirming payment" and poll a few times rather than declaring failure.

If it stays `pending`, your backend can call `GET /checkout/session/{id}` to sync the status directly.

## 3. Receiving webhooks

This is the part that decides whether money is real. Get it right.

### Verifying the signature

Clink signs with HMAC SHA-256 over `timestamp + "." + raw request body`.

On Node, the SDK does this for you:

```javascript theme={null}
import { ClinkWebhook, ClinkWebhookSignatureError } from '@clink-ai/clink-typescript-sdk';

const webhook = new ClinkWebhook({
  signatureKey: process.env.CLINK_WEBHOOK_SIGNING_KEY,
});

// Throws ClinkWebhookSignatureError on failure, returns the parsed event on success
const event = webhook.verifyAndGet({
  timestamp: req.headers['x-clink-timestamp'],
  body: rawBody,
  headerSignature: req.headers['x-clink-signature'],
});
```

<Warning>
  Verify against the **raw request body**. Parsing JSON and re-serializing changes key order and whitespace, and the signature will never match. In Express that means `express.raw()`, not `express.json()`.
</Warning>

<Accordion icon="code" title="Other languages: computing the HMAC yourself">
  The rule is simple enough to implement anywhere:

  ```javascript theme={null}
  import crypto from 'node:crypto';

  function verifySignature(rawBody, headers) {
    const timestamp = headers['x-clink-timestamp'];
    const signature = headers['x-clink-signature'];
    const signType = headers['x-clink-signtype'];

    if (signType !== 'SHA256') return false;

    const expected = crypto
      .createHmac('sha256', process.env.CLINK_WEBHOOK_SIGNING_KEY)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    // Constant-time comparison
    const a = Buffer.from(expected);
    const b = Buffer.from(signature ?? '');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  Three headers carry it: `X-Clink-Timestamp` is the timestamp, `X-Clink-Signature` is the signature, and `X-Clink-SignType` is currently always `SHA256`.
</Accordion>

### The full handler

```javascript theme={null}
// Note: express.raw(), not express.json()
app.post(
  '/api/webhooks/clink',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf8');

    if (!verifySignature(rawBody, req.headers)) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(rawBody);

    // 1. Idempotency: handle each event ID once
    const seen = await db.webhookEvents.findById(event.id);
    if (seen) return res.status(200).send('ok');

    try {
      await handleEvent(event);
      await db.webhookEvents.create({ id: event.id, type: event.type });
    } catch (err) {
      // Do not return 2xx on failure — let Clink retry
      logger.error({ err, eventId: event.id }, 'webhook failed');
      return res.status(500).send('retry later');
    }

    // 2. Only return 200 after the state is persisted
    res.status(200).send('ok');
  }
);

async function handleEvent(event) {
  const obj = event.data.object;

  // Refund events do not carry your order number — look it up by orderId
  if (event.type.startsWith('refund.')) {
    const local = await db.orders.findByClinkOrderId(obj.orderId);
    if (!local) return;

    if (event.type === 'refund.succeeded') {
      await db.orders.update(local.id, { paymentStatus: 'refunded' });
      await revokeOnce(local.id);
    }
    return;
  }

  if (!event.type.startsWith('order.')) return;

  // These three steps apply to every order.* event, so they live outside the switch

  // 1. Match on both the order number and the Session
  const local = await db.orders.findById(obj.merchantReferenceId);
  if (!local || local.clinkSessionId !== obj.sessionId) {
    logger.warn({ orderId: obj.orderId }, 'order mismatch, skipped');
    return;
  }

  // 2. Terminal states stay put. A late failure must not undo a success
  if (['paid', 'refunded'].includes(local.paymentStatus)) return;

  switch (event.type) {
    case 'order.succeeded':
      await db.orders.update(local.id, {
        paymentStatus: 'paid',
        clinkOrderId: obj.orderId,
      });
      await fulfillOnce(local.id);       // 3. Fulfillment is idempotent too
      break;

    case 'order.failed':
      await db.orders.update(local.id, {
        paymentStatus: 'payment_failed',
        failureCode: obj.failureCode,
        failureMessage: obj.failureMessage,
      });
      break;

    case 'order.next_action':
      await db.orders.update(local.id, { paymentStatus: 'action_required' });
      break;
  }
}
```

<Warning>
  Order matching, terminal-state protection, and idempotent fulfillment apply to **every** `order.*` event, not just the success branch.

  The common mistake is validating carefully on success and then updating straight from the order number on failure. One late `order.failed` is then enough to mark money you already received as failed, and reverse the fulfillment with it.
</Warning>

Refunds need their own branch because the payload differs: a `refund.*` object has no `merchantReferenceId`, only `orderId` and `refundMerchantOrderId`. Look up your local order by the `clinkOrderId` you stored at payment time.

### All five of these are required

<Steps>
  <Step title="Verify the signature">
    HMAC SHA-256 over the raw body, compared against `X-Clink-Signature`. Return 401 on mismatch.
  </Step>

  <Step title="Deduplicate by event ID">
    Clink retries failed deliveries, so you will see the same event repeatedly. Deduplicate on `event.id` and return 200 for repeats.
  </Step>

  <Step title="Match the order on two fields">
    Check both `merchantReferenceId` and `sessionId`. If only one matches, treat it as an anomaly and do not update anything.
  </Step>

  <Step title="Tolerate out-of-order delivery">
    Clink does not guarantee event order. An order already `paid` or `refunded` must not be moved back to `pending` by an older event arriving late.
  </Step>

  <Step title="Return 2xx only after writing">
    A 200 means "handled". Returning 200 before persisting loses the event permanently.
  </Step>
</Steps>

### Which events to subscribe to

For one-time payments, at least these:

| Event               | What to do                                                                   |
| ------------------- | ---------------------------------------------------------------------------- |
| `order.succeeded`   | Confirm payment, trigger fulfillment                                         |
| `order.failed`      | Record the reason, allow a retry                                             |
| `order.next_action` | The customer needs extra verification; park the order                        |
| `session.expired`   | The entry point expired. Do not let this overwrite a later `order.succeeded` |
| `refund.succeeded`  | Refund settled, revoke access                                                |

Subscription products need their own set of subscription and invoice events — see [Subscriptions](/subscriptions). The full list is in the [Webhook reference](/api-reference/webhook/order), and `GET /webhook/events` returns the currently supported event names.

<Note>
  Subscribe with full event names. A wildcard like `subscription.*` is not a value the API accepts.
</Note>

### Developing locally

The webhook URL must be publicly reachable over HTTPS. localhost, loopback, and private IPs are rejected.

If you already have a public address — a preview deployment, a Vercel or Netlify URL, your own domain — use it. Tunnels are only for purely local development:

```bash theme={null}
cloudflared tunnel --url http://127.0.0.1:3000 --no-autoupdate
```

If QUIC fails to connect, retry with `--protocol http2`.

Tunnel URLs change on restart, so update the webhook endpoint in the dashboard when yours does.

#### Testing verification without paying

A tunnel plus a real payment proves the whole chain, but paying with a card every time you tweak your verification logic is slow. Build a signed request yourself and send it locally:

```javascript theme={null}
// send-test-event.mjs
import crypto from 'node:crypto';

const event = {
  id: 'evt_test_001',                       // new id for a new event, same id to test dedupe
  type: 'order.succeeded',
  object: 'event',
  created: Date.now(),
  data: {
    object: {
      orderId: 'ord_test_001',
      merchantReferenceId: 'order_10001',   // an order that exists in your database
      sessionId: 'sess_test_001',           // must match that order's clinkSessionId
      status: 'success',
    },
  },
};

const body = JSON.stringify(event);
const timestamp = String(Date.now());
const signature = crypto
  .createHmac('sha256', process.env.CLINK_WEBHOOK_SIGNING_KEY)
  .update(`${timestamp}.${body}`)
  .digest('hex');

const res = await fetch('http://127.0.0.1:3000/api/webhooks/clink', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Clink-Timestamp': timestamp,
    'X-Clink-Signature': signature,
    'X-Clink-SignType': 'SHA256',
  },
  body,
});

console.log(res.status, await res.text());
```

Change a few fields and you can cover everything that matters:

| To test            | Change                                                                          |
| ------------------ | ------------------------------------------------------------------------------- |
| Duplicate delivery | Run the same `id` twice — the second should return 200 without fulfilling again |
| Out-of-order       | Run `order.succeeded`, then an `order.failed`; the order should stay paid       |
| Bad signature      | Alter one character of `signature` — expect 401                                 |
| Mismatched order   | Change `sessionId` — the event should be skipped, not applied                   |

## Who owns what

| Component       | Owns                                                                        | Must not                                                                     |
| --------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Backend         | Creating orders, calling Clink, storing IDs, exposing order status          | Return the Secret Key to the browser; charge again while a result is unknown |
| Frontend        | Calling your backend, redirecting or mounting checkout, showing status      | Call Clink directly; fulfill based on a redirect or an SDK event             |
| Webhook handler | Verifying, deduplicating, matching, updating status, triggering fulfillment | Depend on event order; let stale state overwrite newer state                 |

## Common mistakes

**Fulfilling on the return page.** `successUrl` is an ordinary address a customer can type. Only a verified webhook should trigger fulfillment.

**Fulfilling on every webhook without deduplication.** Clink retries, so one purchase becomes three shipments.

**Treating `pending` as failure and letting the customer pay again.** `pending` means "not known yet". Charging again here bills the customer twice. Wait for the webhook or poll `GET /order/{id}`.

**Parsing the webhook body with `express.json()`.** The re-serialized body produces a different signature and every event fails verification.

**A Secret Key in frontend code.** Anyone who finds it can take payments and issue refunds on your account. The browser gets a Publishable Key only.

## Next

<CardGroup cols={2}>
  <Card title="API keys & webhooks" icon="key-round" href="/integration">
    Key rotation, IP restrictions, delivery and retry rules.
  </Card>

  <Card title="Go live" icon="rocket" href="/go-live">
    The scenarios to verify before switching to production.
  </Card>
</CardGroup>
