> ## 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 an integration path has already been picked. 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>
  Signature verification, deduplication, and the order model on this page underlie every kind of payment. The **code does not transfer directly**, though: subscription renewals are recorded and fulfilled per `subscriptionId` + `invoiceId`, which is not the same logic as matching a one-time order on `merchantReferenceId` + `sessionId`.

  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 required

| Endpoint | Does | Does not |
| - | - | - |
| `POST /api/checkout/create` | Validates the item and amount, creates the local 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 the local order

At minimum these fields. Without them nothing can be debugged later.

**Separate payment attempts, refunds, and reconciliation evidence.**

```sql theme={null}
-- Merchant order: one row per checkout
CREATE TABLE merchant_orders (
  id                 TEXT PRIMARY KEY,      -- the merchant order number
  customer_id        TEXT NOT NULL,         -- the user in the merchant system
  product_snapshot   JSONB NOT NULL,        -- name, unit price, quantity, as of purchase time
  original_amount    NUMERIC(18,4) NOT NULL,
  original_currency  TEXT NOT NULL,
  refundable_paid_clink_order_id TEXT,         -- successful Order that established the refund basis
  refundable_paid_amount NUMERIC(18,4),        -- authoritative refundable cash amount
  refundable_paid_currency TEXT,               -- payment/refund currency for that cash amount
  clink_session_id   TEXT,                  -- sessionId returned by Clink
  clink_session_status TEXT NOT NULL DEFAULT 'open'
                       CHECK (clink_session_status IN ('open', 'completed', 'expired')),
  clink_session_last_event_created BIGINT,
  current_clink_order_id TEXT,              -- current payment attempt, inferred or Session-confirmed
  current_attempt_confirmed BOOLEAN NOT NULL DEFAULT FALSE, -- true only after a Session query
  payment_status     TEXT NOT NULL,         -- aggregated from the attempts below
  fulfillment_status TEXT NOT NULL,         -- kept separate from payment status
  refunded_amount    NUMERIC(18,4) NOT NULL DEFAULT 0,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT ck_refundable_payment_basis CHECK (
    (refundable_paid_clink_order_id IS NULL AND refundable_paid_amount IS NULL AND refundable_paid_currency IS NULL)
    OR
    (refundable_paid_clink_order_id IS NOT NULL AND refundable_paid_amount IS NOT NULL AND refundable_paid_currency IS NOT NULL)
  ),
  CONSTRAINT ck_clink_session_event_created CHECK (
    clink_session_last_event_created IS NULL OR clink_session_last_event_created >= 0
  )
);

-- Payment attempt: one row per Clink Order
CREATE TABLE payment_attempts (
  clink_order_id     TEXT PRIMARY KEY,      -- obj.orderId
  merchant_order_id  TEXT NOT NULL REFERENCES merchant_orders(id),
  clink_session_id   TEXT NOT NULL,
  status             TEXT NOT NULL,         -- pending / action_required / succeeded / failed
  attempt_created_at BIGINT,                -- only ever from order.created's event.created
  last_event_created BIGINT NOT NULL,       -- status-event version, compared within one Order only
  failure_code       TEXT,
  failure_message    TEXT,
  received_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),   -- local arrival time, audit only
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Ordering reads attempt_created_at only; unknown ordering sorts last
CREATE INDEX idx_attempts_order
  ON payment_attempts (merchant_order_id, attempt_created_at DESC NULLS LAST);

-- Composite ownership key: pointers and refunds cannot pair one merchant
-- order with another merchant order's Clink Order.
ALTER TABLE payment_attempts
  ADD CONSTRAINT uq_payment_attempt_owner UNIQUE (merchant_order_id, clink_order_id);

ALTER TABLE merchant_orders
  ADD CONSTRAINT fk_current_attempt_owner
    FOREIGN KEY (id, current_clink_order_id)
    REFERENCES payment_attempts (merchant_order_id, clink_order_id),
  ADD CONSTRAINT fk_refundable_attempt_owner
    FOREIGN KEY (id, refundable_paid_clink_order_id)
    REFERENCES payment_attempts (merchant_order_id, clink_order_id);

-- Successful refund: immutable business fields, one row per Clink refundId
CREATE TABLE refunds (
  refund_id           TEXT PRIMARY KEY,
  merchant_order_id   TEXT NOT NULL REFERENCES merchant_orders(id),
  clink_order_id      TEXT NOT NULL REFERENCES payment_attempts(clink_order_id),
  amount              NUMERIC(18,4) NOT NULL CHECK (amount > 0),
  currency            TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
  status              TEXT NOT NULL CHECK (status = 'success'),
  first_event_id      TEXT NOT NULL,          -- audit only; not rewritten on an exact replay
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT fk_refund_attempt_owner
    FOREIGN KEY (merchant_order_id, clink_order_id)
    REFERENCES payment_attempts (merchant_order_id, clink_order_id)
);

CREATE INDEX idx_refunds_success
  ON refunds (merchant_order_id, status);

-- Durable evidence for conflicts that automation must not retry or overwrite
CREATE TABLE reconciliation_cases (
  dedupe_key          TEXT PRIMARY KEY,       -- stable business key, not a random task id
  event_id            TEXT NOT NULL,
  merchant_order_id   TEXT NOT NULL REFERENCES merchant_orders(id),
  clink_order_id      TEXT,
  refund_id           TEXT,
  reason              TEXT NOT NULL,
  evidence            JSONB NOT NULL,
  status              TEXT NOT NULL DEFAULT 'pending',
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

For an installation that already has `merchant_orders`, apply the Session-event version column before deploying the handler:

```sql theme={null}
ALTER TABLE merchant_orders
  ADD COLUMN IF NOT EXISTS clink_session_last_event_created BIGINT;

ALTER TABLE merchant_orders
  DROP CONSTRAINT IF EXISTS ck_clink_session_event_created;
ALTER TABLE merchant_orders
  ADD CONSTRAINT ck_clink_session_event_created CHECK (
    clink_session_last_event_created IS NULL OR clink_session_last_event_created >= 0
  );
```

`paymentAttempts.insertIfAbsent()` is not a read-then-write helper. It must let the `clink_order_id` primary key arbitrate concurrent first deliveries:

```sql theme={null}
INSERT INTO payment_attempts
  (clink_order_id, merchant_order_id, clink_session_id, status,
   attempt_created_at, last_event_created, failure_code, failure_message)
VALUES
  ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (clink_order_id) DO NOTHING
RETURNING *;
```

When this returns no row, read the winner and validate both `merchant_order_id` and `clink_session_id` before any update. A different owner returns `manual_review`; never lock that other merchant order or overwrite its attempt.

`refunds` rows are immutable. `insertIfAbsent()` below means this SQL, followed by a read of the stored row when nothing was returned:

```sql theme={null}
INSERT INTO refunds
  (refund_id, merchant_order_id, clink_order_id, amount, currency, status, first_event_id)
VALUES
  ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (refund_id) DO NOTHING
RETURNING *;
```

An existing `refund_id` is an exact replay only when its merchant order, Clink Order, Decimal amount, normalized currency, and status all match. Never use `DO UPDATE`: a different immutable value is evidence for manual reconciliation, not a correction to the old row. `reconciliation_cases.insertIfAbsent()` uses the same `INSERT ... ON CONFLICT (dedupe_key) DO NOTHING` pattern.

```sql theme={null}
INSERT INTO reconciliation_cases
  (dedupe_key, event_id, merchant_order_id, clink_order_id, refund_id, reason, evidence)
VALUES
  ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (dedupe_key) DO NOTHING;
```

<Note>
  This example uses `decimal.js` for money arithmetic:

  ```bash theme={null}
  npm install decimal.js
  ```

  An existing Decimal library is equally valid. Keep money as database `NUMERIC` values and application-layer Decimal values — never binary floating point.
</Note>

<Warning>
  **Refund-basis scope:** the automatic refund logic below applies only to Hosted Checkout payments where the payment uses a single cash funding source and the successful Order's `amountTotal` is the authoritative refundable cash amount. The public Order contract exposes `amountTotal` and `paymentCurrency`, but not a separate `cashAmount` for balance/points plus cash splits.

  If mixed funding is enabled, do **not** substitute `originalAmount` or total `amountTotal`. Obtain an authoritative refundable-cash field from the backend contract first; until then, route those orders to manual reconciliation instead of running the automated refund-status calculation.
</Warning>

<Note>
  A deterministic data conflict returns `manual_review`; it is not thrown as a retryable error. Before that result is returned, the same transaction writes an immutable `reconciliation_cases` row and a deduplicated `manual_reconciliation` Outbox task. Only then may the Webhook be marked `processed` and acknowledged. The Worker opens or updates the operations case and alert using the stable case key.
</Note>

<Warning>
  **Never use the local insert time to decide which attempt is current.** `received_at` is when the merchant database wrote the row — shaped by push order, retries, and processing delay, and unrelated to when Clink created the Order. It exists for diagnosis.

  Exactly one source carries Order creation order: **the outer `event.created` of an `order.created` event**. On every other `order.*` event, `event.created` is when that status occurred, not when the Order was created; `data.object` carries no Order creation time, and `GET /order/{orderId}` does not return one today.

  So `order.created` **must be subscribed**. All four are required: `order.created`, `order.next_action`, `order.succeeded`, `order.failed`.
</Warning>

**Why one table is not enough.** A single Session can produce **several Orders** — a declined first card followed by a successful second card is two Orders. Write every `order.*` event into the merchant order row and a late failure from the first attempt overwrites the success of the second.

So store **one row per payment attempt**, keyed by `obj.orderId`, and derive the merchant order's payment status by **aggregating** those attempts.

<Note>
  The SQL below uses `snake_case` column names; the JavaScript examples use `camelCase` properties throughout (`clinkOrderId`, `clinkSessionStatus`, `paymentStatus`, `attemptCreatedAt`). They refer to the same fields — leave the mapping to your ORM or query layer.

  Every `tx.*` / `db.*` repository method in this page is therefore assumed to return camelCase objects. When bypassing that layer with raw node-postgres SQL, alias every consumed column explicitly, as `claimTask()` does below. Never return a mixed object such as camelCase business IDs with a snake\_case `claim_token`.
</Note>

Snapshot the product at purchase time. After a price change, what this customer actually paid must still be recoverable.

Payment status and fulfillment status are separate columns because they go out of sync: money arrives, fulfillment fails, and those orders have to be findable and retryable.

Session lifecycle is separate too. `session.complete` advances `clinkSessionStatus` to `completed`, while `session.expired` advances it to `expired`; neither event decides payment, refund, fulfillment, or Attempt state. `clinkSessionLastEventCreated` stores the terminal Session event's Unix-millisecond version so a late older terminal event cannot overwrite a newer one. Build customer-facing copy from the Session status, the aggregate payment status, and whether a pending/action-required attempt still exists.

## 1. Create the order and the Session

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

  function assertClinkPath(path) {
    let decodedPath = '';
    try {
      decodedPath = decodeURIComponent(String(path).split(/[?#]/, 1)[0]);
    } catch {
      throw new Error('Clink API path must be a controlled relative path');
    }
    if (
      typeof path !== 'string' ||
      !path.startsWith('/') ||
      path.startsWith('//') ||
      path.includes('\\') ||
      decodedPath.startsWith('//') ||
      decodedPath.split('/').includes('..')
    ) {
      throw new Error('Clink API path must be a controlled relative path');
    }
  }

  function throwSanitizedClinkError(method, path, detail) {
    // Only method, controlled path, HTTP status, and numeric envelope code are
    // allowed into the error. Never copy the key, response body, or json.msg.
    throw new Error(`Clink ${method} ${path} failed${detail ? `: ${detail}` : ''}`);
  }

  async function clinkApiRequest(path, { method, body, signal } = {}) {
    assertClinkPath(path);
    if (method !== 'GET' && method !== 'POST') {
      throw new Error('Clink API method must be GET or POST');
    }

    const apiKey = process.env.CLINK_SECRET_KEY;
    if (!apiKey) throw new Error('CLINK_SECRET_KEY is not configured');

    const headers = {
      'X-API-Key': apiKey,
      'X-Timestamp': String(Date.now()),
      Accept: 'application/json',
    };
    const options = { method, headers, signal };
    if (method === 'POST') {
      headers['Content-Type'] = 'application/json';
      options.body = JSON.stringify(body);
    }

    let res;
    try {
      res = await fetch(`${CLINK_API}${path}`, options);
    } catch (err) {
      if (err?.name === 'AbortError') throw err;
      throwSanitizedClinkError(method, path, 'request error');
    }

    let json;
    try {
      json = await res.json();
    } catch (err) {
      if (err?.name === 'AbortError') throw err;
      throwSanitizedClinkError(method, path, 'invalid JSON response');
    }

    const dataIsObject =
      json?.data !== null && typeof json?.data === 'object' && !Array.isArray(json.data);
    if (!res.ok || json?.code !== 200 || !dataIsObject) {
      const details = [];
      if (Number.isInteger(res.status)) details.push(`HTTP ${res.status}`);
      if (Number.isInteger(json?.code)) details.push(`code ${json.code}`);
      throwSanitizedClinkError(method, path, details.join(', ') || 'invalid response envelope');
    }
    return json.data;
  }

  async function clinkRequest(path, body, { signal } = {}) {
    return clinkApiRequest(path, { method: 'POST', body, signal });
  }

  async function clinkGet(path, { signal } = {}) {
    return clinkApiRequest(path, { method: 'GET', signal });
  }

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

    // Price comes from the local 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.merchantOrders.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.merchantOrders.update(order.id, {
      clinkSessionId: session.sessionId,
      clinkSessionStatus: 'open',
      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.merchantOrders.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.merchantOrders.update(order.id, {
      clinkSessionId: session.sessionId,
      clinkSessionStatus: 'open',
      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 to show where each field goes.
</Note>

Things that bite people:

* **Read the price from the local 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 the merchant order number.** Clink does not use it for idempotency — the same value twice produces two Sessions. Preventing duplicate orders is the merchant’s responsibility.
* **`referenceCustomerId` is the merchant 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 the local 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`, the 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`.

A complete verification does three things, and skipping any one of them means it is not verified: **check `signType`, check timestamp freshness, and compare the signature in constant time.** Here is the whole thing:

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

const REPLAY_WINDOW_MS = 5 * 60 * 1000;   // 5 minutes

function verifyWebhook(rawBody, headers) {
  // 1. The algorithm must be SHA256 — stop here otherwise
  if (headers['x-clink-signtype'] !== 'SHA256') return false;

  // 2. The timestamp is Unix milliseconds. Reject stale or non-numeric values
  const ts = Number(headers['x-clink-timestamp']);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return false;

  // 3. HMAC over the raw body — nothing has been JSON.parse'd yet
  const expected = crypto
    .createHmac('sha256', process.env.CLINK_WEBHOOK_SIGNING_KEY)
    .update(`${headers['x-clink-timestamp']}.${rawBody}`)
    .digest('hex');

  // 4. Constant-time compare. Screen the length first — timingSafeEqual
  //    throws when the two buffers differ in size
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(headers['x-clink-signature'] ?? '', 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

`X-Clink-Timestamp` is a Unix **millisecond** timestamp, which is why it compares directly against `Date.now()`. The 5-minute window is this example's choice — the platform defines no required value. A wider window leaves a captured request replayable for longer; a narrower one is more sensitive to clock drift. Keep the server on NTP.

<Warning>
  **Both the freshness check and the HMAC must run before `JSON.parse`, against the raw body.** Parsing and re-serializing changes key order and whitespace, and the signature will never match. In Express that means `express.raw()`, not `express.json()`.
</Warning>

<Warning>
  **The time window stops replays, not duplicates.** Clink's own retries arrive inside it, so the same event still arrives several times. Deduplicate separately and atomically on `event.id` — see the next section.
</Warning>

<Accordion icon="package" title="Using the TypeScript SDK">
  `ClinkWebhook.verifyAndGet()` in `@clink-ai/clink-typescript-sdk@1.0.1` **only does the HMAC step** — one of the three things above:

  * It does not check `X-Clink-SignType`
  * It does not check timestamp freshness
  * It compares signatures with `===`, not a constant-time comparison

  The first two can be screened before calling it. The third is inside the SDK and cannot be worked around.

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

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

  function verifyWithSdk(rawBody, headers) {
    if (headers['x-clink-signtype'] !== 'SHA256') return null;

    const ts = Number(headers['x-clink-timestamp']);
    if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return null;

    try {
      return webhook.verifyAndGet({
        timestamp: headers['x-clink-timestamp'],
        body: rawBody,
        headerSignature: headers['x-clink-signature'],
      });
    } catch (e) {
      if (e instanceof ClinkWebhookSignatureError) return null;
      throw e;
    }
  }
  ```

  Until the SDK offers a constant-time comparison, **use the local implementation above for production integrations**.
</Accordion>

### The full handler

`webhook_events` is not only a dedup table — it doubles as a **pending queue**. It needs at least these columns:

```sql theme={null}
CREATE TABLE webhook_events (
  id             TEXT PRIMARY KEY,       -- event.id; names the webhook_events_pkey constraint
  type           TEXT NOT NULL,
  payload        TEXT NOT NULL,          -- exact raw event body, needed when reprocessing
  status         TEXT NOT NULL DEFAULT 'pending'
                 CHECK (status IN ('pending', 'processing', 'processed', 'failed')),
  retry_count    INT NOT NULL DEFAULT 0,
  next_retry_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_error     TEXT,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

```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 (!verifyWebhook(rawBody, req.headers)) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(rawBody);

    try {
      await db.transaction(async (tx) => {
        // 1. Persist and deduplicate. event.id carries a unique index,
        //    so only one concurrent delivery gets past this line
        await tx.webhookEvents.insert({
          id: event.id,
          type: event.type,
          payload: rawBody,
          status: 'pending',
        });

        // 2. Handle it. Both normal completion and a durably quarantined
        //    conflict are terminal; a missing dependency stays pending.
        const outcome = await handleEvent(event, tx);

        if (outcome === 'done' || outcome === 'manual_review') {
          await tx.webhookEvents.update(event.id, { status: 'processed' });
        } else {
          // outcome === 'deferred': the dependency does not exist yet.
          // Queue a reprocess task and leave the event pending
          await tx.outbox.insert({
            eventId: event.id,
            task: 'reprocess_webhook',
            dedupeKey: `reprocess_webhook:${event.id}`,
          });
        }
      });
    } catch (err) {
      // Duplicate delivery: match ONLY the webhook_events constraint
      if (isDuplicateEvent(err)) return res.status(200).send('ok');

      // An unexpected or transient failure. Deterministic conflicts never
      // reach this branch: their manual-review evidence commits with the
      // event. Here the transaction rolled back, so Clink should retry.
      logger.error({ err, eventId: event.id }, 'webhook failed');
      return res.status(500).send('retry later');
    }

    // 3. Acknowledge only once the event and its reprocess task are durable
    res.status(200).send('ok');
  }
);

// Match the constraint by name. "Is it a unique violation?" is too broad.
function isDuplicateEvent(err) {
  return err.code === '23505' && err.constraint === 'webhook_events_pkey';
}
```

<Warning>
  **Never mark an event `processed` when its dependency does not exist.**

  `refund.succeeded` can perfectly well arrive before `order.succeeded`. Returning early with a 200 at that point records the event as handled — Clink stops delivering it, and that refund is never applied.

  Keep a missing dependency `pending`, queue a reprocess task, and let the worker retry once it exists. Set `processed` only after either the business work is complete or a deterministic conflict and its manual-review task have been durably quarantined in the same transaction.
</Warning>

`handleEvent` returns `'done'`, `'deferred'`, or `'manual_review'`. `manual_review` is terminal only because the conflict evidence and its operations task have already been persisted in the same transaction.

**The order of steps inside the transaction is fixed.** Refunds and order events follow the same sequence, and the identical lock order is what keeps them from deadlocking:

<Steps>
  <Step title="Insert and deduplicate webhook_events" />

  <Step title="Locate the merchant order and lock the row with SELECT ... FOR UPDATE" />

  <Step title="Validate required identifiers and the merchant order's Session" />

  <Step title="Insert-or-read payment_attempts, then validate its merchant and Session owner" />

  <Step title="Re-read all attempts for that order inside the lock" />

  <Step title="Aggregate and update paymentStatus" />

  <Step title="Write the fulfillment / reconciliation outbox row in the same transaction" />

  <Step title="Mark processed, or stay pending when a dependency is missing" />
</Steps>

```javascript theme={null}
import Decimal from 'decimal.js';

const ATTEMPT_STATUS = {
  'order.created':     'pending',
  'order.next_action': 'action_required',
  'order.succeeded':   'succeeded',
  'order.failed':      'failed',
};
const TERMINAL = new Set(['succeeded', 'failed']);

// Single-attempt status -> merchant order status
const ATTEMPT_TO_ORDER = {
  succeeded:       'paid',
  failed:          'payment_failed',
  action_required: 'action_required',
  pending:         'pending',
};

function tryNormalizeCurrency(value) {
  const currency = String(value ?? '').trim().toUpperCase();
  return /^[A-Z]{3}$/.test(currency) ? currency : null;
}

function tryPositiveDecimal(value) {
  try {
    const amount = new Decimal(String(value));
    return amount.isFinite() && amount.isPositive() ? amount : null;
  } catch {
    return null;
  }
}

// Manual-review evidence is deliberately allowlisted. Never copy a whole
// Webhook/API object: it may contain customerEmail, metadata, or payment data.
function attemptEvidence(attempt) {
  if (!attempt) return null;
  return {
    clinkOrderId: attempt.clinkOrderId,
    merchantOrderId: attempt.merchantOrderId,
    clinkSessionId: attempt.clinkSessionId,
    status: attempt.status,
    attemptCreatedAt: attempt.attemptCreatedAt ?? null,
    lastEventCreated: attempt.lastEventCreated,
  };
}

function orderEvidence(order) {
  if (!order) return null;
  return {
    orderId: order.orderId ?? null,
    sessionId: order.sessionId ?? null,
    merchantReferenceId: order.merchantReferenceId ?? null,
    status: order.status ?? null,
    amountTotal: order.amountTotal == null ? null : String(order.amountTotal),
    paymentCurrency: order.paymentCurrency ?? null,
    failureCode: order.failureCode ?? null,
  };
}

function refundEvidence(refund) {
  if (!refund) return null;
  return {
    refundId: refund.refundId ?? null,
    merchantOrderId: refund.merchantOrderId ?? null,
    clinkOrderId: refund.clinkOrderId ?? refund.orderId ?? null,
    amount: refund.amount == null ? (refund.refundAmount == null ? null : String(refund.refundAmount)) : String(refund.amount),
    currency: refund.currency ?? refund.refundCurrency ?? null,
    status: refund.status ?? null,
  };
}

function sessionEvidence(session) {
  if (!session) return null;
  return {
    sessionId: session.sessionId ?? null,
    orderId: session.orderId ?? null,
    merchantReferenceId: session.merchantReferenceId ?? null,
    status: session.status ?? null,
  };
}

// Persist both the case and the notification task before returning a terminal
// manual_review result. Neither insert updates the first evidence on a replay.
async function recordManualReview(tx, review) {
  await tx.reconciliationCases.insertIfAbsent({
    dedupeKey: review.dedupeKey,
    eventId: review.eventId,
    merchantOrderId: review.merchantOrderId,
    clinkOrderId: review.clinkOrderId ?? null,
    refundId: review.refundId ?? null,
    reason: review.reason,
    evidence: review.evidence,
    status: 'pending',
  });
  await tx.outbox.insert({
    eventId: review.eventId,
    task: 'manual_reconciliation',
    orderId: review.merchantOrderId,
    clinkOrderId: review.clinkOrderId ?? null,
    payload: { caseKey: review.dedupeKey },
    dedupeKey: `manual_reconciliation:${review.dedupeKey}`,
  });
  return 'manual_review';
}

function recordOrderOwnershipReview(tx, merchantOrder, event, incoming, storedAttempt, reason) {
  const orderKey = incoming.clinkOrderId ?? `event:${event.id}`;
  return recordManualReview(tx, {
    dedupeKey: `order_ownership_conflict:${merchantOrder.id}:${orderKey}`,
    eventId: event.id,
    merchantOrderId: merchantOrder.id,
    clinkOrderId: incoming.clinkOrderId ?? null,
    reason,
    evidence: {
      reason,
      eventId: event.id,
      eventType: event.type,
      eventCreated: Number.isFinite(event.created) ? event.created : null,
      incoming: {
        merchantOrderId: merchantOrder.id,
        clinkSessionId: incoming.clinkSessionId ?? null,
        clinkOrderId: incoming.clinkOrderId ?? null,
      },
      storedMerchantOrder: {
        merchantOrderId: merchantOrder.id,
        clinkSessionId: merchantOrder.clinkSessionId,
        currentClinkOrderId: merchantOrder.currentClinkOrderId ?? null,
      },
      storedAttempt: attemptEvidence(storedAttempt),
    },
  });
}

function recordSessionOwnershipReview(tx, merchantOrder, event, session, reason) {
  const incomingSessionId = session?.sessionId ?? null;
  return recordManualReview(tx, {
    dedupeKey:
      `session_review:${merchantOrder.id}:${reason}:${event.type}:${incomingSessionId ?? 'missing'}`,
    eventId: event.id,
    merchantOrderId: merchantOrder.id,
    clinkOrderId: session?.orderId ?? null,
    reason,
    evidence: {
      reason,
      eventId: event.id,
      eventType: event.type,
      eventCreated: Number.isFinite(event.created) ? event.created : null,
      incoming: {
        merchantOrderId: session?.merchantReferenceId ?? null,
        clinkSessionId: incomingSessionId,
        clinkOrderId: session?.orderId ?? null,
        status: session?.status ?? null,
      },
      storedMerchantOrder: {
        merchantOrderId: merchantOrder.id,
        clinkSessionId: merchantOrder.clinkSessionId,
        clinkSessionStatus: merchantOrder.clinkSessionStatus,
        clinkSessionLastEventCreated:
          merchantOrder.clinkSessionLastEventCreated ?? null,
      },
    },
  });
}

const SESSION_STATUS_BY_EVENT = {
  'session.complete': 'completed',
  'session.expired':  'expired',
};

async function handleSessionTerminal(event, session, tx) {
  if (!session || typeof session !== 'object' || Array.isArray(session)) {
    return 'deferred';
  }
  // Without a merchant reference there is no safe owner row for a durable case.
  if (!session.merchantReferenceId) return 'deferred';

  const merchantOrder = await tx.merchantOrders.findByIdForUpdate(
    session.merchantReferenceId,
  );
  if (!merchantOrder) return 'deferred';

  if (!session.sessionId) {
    return recordSessionOwnershipReview(
      tx,
      merchantOrder,
      event,
      session,
      'session_event_missing_required_identifier',
    );
  }
  if (merchantOrder.clinkSessionId !== session.sessionId) {
    return recordSessionOwnershipReview(
      tx,
      merchantOrder,
      event,
      session,
      'session_event_ownership_conflict',
    );
  }

  const nextStatus = SESSION_STATUS_BY_EVENT[event.type];
  if (!nextStatus) return 'deferred';
  if (!Number.isSafeInteger(event.created) || event.created < 0) {
    return recordSessionOwnershipReview(
      tx,
      merchantOrder,
      event,
      session,
      'session_event_invalid_created',
    );
  }
  if (session.status != null && session.status !== nextStatus) {
    return recordSessionOwnershipReview(
      tx,
      merchantOrder,
      event,
      session,
      'session_event_status_mismatch',
    );
  }

  const previousCreated = merchantOrder.clinkSessionLastEventCreated ?? null;
  if (previousCreated != null && event.created < previousCreated) {
    return 'done';
  }
  if (previousCreated === event.created) {
    if (merchantOrder.clinkSessionStatus === nextStatus) return 'done';
    return recordSessionOwnershipReview(
      tx,
      merchantOrder,
      event,
      session,
      'session_terminal_same_version_conflict',
    );
  }

  await tx.merchantOrders.update(merchantOrder.id, {
    clinkSessionStatus: nextStatus,
    clinkSessionLastEventCreated: event.created,
  });
  merchantOrder.clinkSessionStatus = nextStatus;
  merchantOrder.clinkSessionLastEventCreated = event.created;

  // Session lifecycle never changes the payment aggregate, refund status,
  // fulfillment status, or an Attempt terminal state.
  return 'done';
}

// Call only while holding the merchant-order row lock. The first successful
// Order establishes the refund basis; exact replays are harmless, but a
// different Order, amount, or currency is a duplicate-payment/data conflict.
async function persistRefundablePaymentBasis(tx, merchantOrder, payment, context) {
  const existing = {
    clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
    amount: merchantOrder.refundablePaidAmount,
    currency: merchantOrder.refundablePaidCurrency,
  };
  const incoming = {
    clinkOrderId: payment.clinkOrderId ?? null,
    amount: payment.amountTotal == null ? null : String(payment.amountTotal),
    currency: payment.paymentCurrency ?? null,
  };
  const dedupeKey = context.dedupeKey ??
    `refund_basis_conflict:${merchantOrder.id}:${payment.clinkOrderId ?? 'missing'}`;
  const amount = tryPositiveDecimal(payment.amountTotal);
  const currency = tryNormalizeCurrency(payment.paymentCurrency);

  const successfulAttempt = payment.clinkOrderId
    ? await tx.paymentAttempts.findByClinkOrderId(payment.clinkOrderId)
    : null;
  if (
    !successfulAttempt ||
    successfulAttempt.merchantOrderId !== merchantOrder.id ||
    successfulAttempt.clinkSessionId !== merchantOrder.clinkSessionId ||
    successfulAttempt.status !== 'succeeded'
  ) {
    return recordManualReview(tx, {
      dedupeKey,
      eventId: context.eventId,
      merchantOrderId: merchantOrder.id,
      clinkOrderId: payment.clinkOrderId ?? null,
      refundId: context.refundId ?? null,
      reason: 'refund_basis_attempt_ownership_conflict',
      evidence: {
        storedBasis: existing,
        incomingPayment: incoming,
        storedAttempt: attemptEvidence(successfulAttempt),
      },
    });
  }

  if (!payment.clinkOrderId || !amount || !currency) {
    return recordManualReview(tx, {
      dedupeKey,
      eventId: context.eventId,
      merchantOrderId: merchantOrder.id,
      clinkOrderId: payment.clinkOrderId ?? null,
      refundId: context.refundId ?? null,
      reason: 'invalid_refund_payment_basis',
      evidence: { stored: existing, incoming },
    });
  }

  const isEmpty = existing.clinkOrderId == null && existing.amount == null && existing.currency == null;

  if (isEmpty) {
    const basis = {
      refundablePaidClinkOrderId: payment.clinkOrderId,
      refundablePaidAmount: amount.toString(),
      refundablePaidCurrency: currency,
    };
    await tx.merchantOrders.update(merchantOrder.id, basis);
    Object.assign(merchantOrder, basis);
    return 'stored';
  }

  const existingAmount = tryPositiveDecimal(existing.amount);
  const existingCurrency = tryNormalizeCurrency(existing.currency);
  const isExactReplay =
    existing.clinkOrderId === payment.clinkOrderId &&
    existingAmount?.equals(amount) === true &&
    existingCurrency === currency;

  if (!isExactReplay) {
    return recordManualReview(tx, {
      dedupeKey,
      eventId: context.eventId,
      merchantOrderId: merchantOrder.id,
      clinkOrderId: payment.clinkOrderId,
      refundId: context.refundId ?? null,
      reason: 'refund_payment_basis_conflict',
      evidence: { stored: existing, incoming: { ...incoming, amount: amount.toString(), currency } },
    });
  }
  return 'replayed';
}

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

  if (SESSION_STATUS_BY_EVENT[event.type]) {
    return handleSessionTerminal(event, obj, tx);
  }
  if (event.type.startsWith('refund.')) {
    return obj ? handleRefund(event, obj, tx) : 'deferred';
  }
  if (!event.type.startsWith('order.')) return 'done';

  const nextStatus = ATTEMPT_STATUS[event.type];
  if (!nextStatus) return 'done';

  // Without the merchant reference there is no merchant row under which a
  // durable case can be filed. Treat it as unresolved input, not a DB error.
  if (!obj?.merchantReferenceId) return 'deferred';

  // Lock the merchant order. This serializes every event for that order —
  // the webhook_events unique index only stops the same event.id, not two
  // different events racing on one order
  const merchantOrder = await tx.merchantOrders.findByIdForUpdate(obj.merchantReferenceId);
  if (!merchantOrder) return 'deferred';

  const incomingOwnership = {
    clinkOrderId: obj.orderId ?? null,
    clinkSessionId: obj.sessionId ?? null,
  };
  if (!obj.orderId || !obj.sessionId || !Number.isSafeInteger(event.created)) {
    return recordOrderOwnershipReview(
      tx,
      merchantOrder,
      event,
      incomingOwnership,
      null,
      'order_event_missing_required_identifier',
    );
  }
  if (merchantOrder.clinkSessionId !== obj.sessionId) {
    const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(obj.orderId);
    return recordOrderOwnershipReview(
      tx,
      merchantOrder,
      event,
      incomingOwnership,
      storedAttempt,
      'order_event_session_mismatch',
    );
  }

  const attemptResult = await upsertAttempt(tx, {
    eventId: event.id,                             // needed by reconcile_attempt
    clinkOrderId: obj.orderId,
    merchantOrderId: merchantOrder.id,
    clinkSessionId: obj.sessionId,
    status: nextStatus,
    eventType: event.type,
    eventCreated: event.created,
    failureCode: obj.failureCode,
    failureMessage: obj.failureMessage,
  }, merchantOrder, event);
  if (attemptResult.outcome === 'manual_review') return attemptResult.outcome;

  if (
    attemptResult.inserted &&
    attemptResult.attempt.attemptCreatedAt == null &&
    merchantOrder.currentAttemptConfirmed &&
    merchantOrder.currentClinkOrderId !== obj.orderId
  ) {
    // A new unordered Order was not covered by the old Session confirmation.
    await tx.merchantOrders.update(merchantOrder.id, {
      currentAttemptConfirmed: false,
    });
    merchantOrder.currentAttemptConfirmed = false;
  }

  if (event.type === 'order.succeeded') {
    // Persist even when order.created has not arrived yet. Do not trust a
    // conflicting succeeded event unless the stored attempt actually converged
    // to succeeded.
    const storedAttempt = attemptResult.attempt;
    if (storedAttempt?.status === 'succeeded') {
      const basisOutcome = await persistRefundablePaymentBasis(
        tx,
        merchantOrder,
        {
          clinkOrderId: obj.orderId,
          amountTotal: obj.amountTotal,
          paymentCurrency: obj.paymentCurrency,
        },
        { eventId: event.id },
      );
      // The attempt write above and the manual-review evidence now commit
      // together. Do not aggregate or throw after quarantining the conflict.
      if (basisOutcome === 'manual_review') return basisOutcome;
    }
  }

  // order.created brought a creation time — see if the current attempt moves
  if (event.type === 'order.created') {
    const pointerOutcome = await maybeAdvanceCurrentAttempt(
      tx,
      merchantOrder,
      obj.orderId,
      event.created,
      event,
      attemptResult.inserted,
    );
    if (pointerOutcome === 'manual_review') return pointerOutcome;
  }

  const aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, event.id);
  if (aggregateOutcome !== 'done') return aggregateOutcome;

  // A same-timestamp status ambiguity still needs its Order query even when
  // the merchant aggregate can be kept safe in the meantime.
  return attemptResult.deferReason === 'status_timestamp_tie'
    ? 'deferred'
    : 'done';
}

// order.created can infer the current attempt only while creation times are
// unambiguous. A tie invalidates that inference and makes the Session decide.
async function maybeAdvanceCurrentAttempt(
  tx,
  merchantOrder,
  clinkOrderId,
  attemptCreatedAt,
  event,
  isNewAttempt,
) {
  const currentId = merchantOrder.currentClinkOrderId;
  if (!currentId) {
    await tx.merchantOrders.update(merchantOrder.id, {
      currentClinkOrderId: clinkOrderId,
      currentAttemptConfirmed: false,
    });
    merchantOrder.currentClinkOrderId = clinkOrderId;
    merchantOrder.currentAttemptConfirmed = false;
    return;
  }

  const cur = await tx.paymentAttempts.findByClinkOrderId(currentId);
  if (
    !cur ||
    cur.merchantOrderId !== merchantOrder.id ||
    cur.clinkSessionId !== merchantOrder.clinkSessionId
  ) {
    return recordOrderOwnershipReview(
      tx,
      merchantOrder,
      event,
      { clinkOrderId: currentId, clinkSessionId: merchantOrder.clinkSessionId },
      cur,
      'current_attempt_pointer_ownership_conflict',
    );
  }
  if (
    merchantOrder.currentAttemptConfirmed &&
    clinkOrderId !== currentId &&
    !isNewAttempt
  ) {
    // A delayed order.created for an Attempt already covered by the last
    // Session snapshot only fills its creation time; it cannot override that
    // newer authoritative Session answer.
    return;
  }
  // The incumbent has no known creation time, or the newcomer is strictly later
  if (cur.attemptCreatedAt == null || attemptCreatedAt > cur.attemptCreatedAt) {
    await tx.merchantOrders.update(merchantOrder.id, {
      currentClinkOrderId: clinkOrderId,
      currentAttemptConfirmed: false,
    });
    merchantOrder.currentClinkOrderId = clinkOrderId;
    merchantOrder.currentAttemptConfirmed = false;
    return;
  }

  if (
    clinkOrderId !== currentId &&
    attemptCreatedAt === cur.attemptCreatedAt
  ) {
    // A different Order has the same creation time. The stored pointer may
    // merely reflect arrival order, and even an older Session answer is stale
    // once this newly observed Order exists. Force a fresh Session query.
    await tx.merchantOrders.update(merchantOrder.id, {
      currentAttemptConfirmed: false,
    });
    merchantOrder.currentAttemptConfirmed = false;
  }
}

// Returns { outcome: applied | ignored | deferred | manual_review, attempt }.
// The primary key, not a read-then-insert race, chooses the first owner.
async function upsertAttempt(tx, a, merchantOrder, event) {
  let cur = await tx.paymentAttempts.findByClinkOrderId(a.clinkOrderId);
  const isCreated = a.eventType === 'order.created';
  let inserted = false;

  if (!cur) {
    const candidate = {
      clinkOrderId: a.clinkOrderId,
      merchantOrderId: a.merchantOrderId,
      clinkSessionId: a.clinkSessionId,
      status: a.status,
      // Only order.created's event.created represents Order creation time
      attemptCreatedAt: isCreated ? a.eventCreated : null,
      lastEventCreated: a.eventCreated,
      failureCode: a.failureCode ?? null,
      failureMessage: a.failureMessage ?? null,
    };
    const winner = await tx.paymentAttempts.insertIfAbsent(candidate);
    cur = winner ?? await tx.paymentAttempts.findByClinkOrderId(a.clinkOrderId);
    if (!cur) throw new Error(`attempt ${a.clinkOrderId} disappeared after insert`);
    inserted = Boolean(winner);
  }

  if (
    cur.merchantOrderId !== merchantOrder.id ||
    cur.clinkSessionId !== a.clinkSessionId
  ) {
    const outcome = await recordOrderOwnershipReview(
      tx,
      merchantOrder,
      event,
      { clinkOrderId: a.clinkOrderId, clinkSessionId: a.clinkSessionId },
      cur,
      'payment_attempt_ownership_conflict',
    );
    return { outcome, attempt: cur, inserted };
  }

  if (inserted) {
    // Success is globally decisive even without order.created. Other status
    // events need Session reconciliation before they can replace an older result.
    const canAggregate = isCreated || a.status === 'succeeded';
    return {
      outcome: canAggregate ? 'applied' : 'deferred',
      attempt: cur,
      inserted: true,
      deferReason: canAggregate ? null : 'attempt_creation_unknown',
    };
  }

  if (isCreated) {
    // A late order.created only fills in the creation time; it never
    // drags the status back to pending
    if (cur.attemptCreatedAt == null) {
      await tx.paymentAttempts.update(a.clinkOrderId, { attemptCreatedAt: a.eventCreated });
      return {
        outcome: 'applied',
        attempt: { ...cur, attemptCreatedAt: a.eventCreated },
        inserted: false,
      };   // ordering is now known, so re-aggregate
    }
    return { outcome: 'ignored', attempt: cur, inserted: false };
  }

  // Within one Order, event.created orders the status events
  if (a.eventCreated < cur.lastEventCreated) {
    return { outcome: 'ignored', attempt: cur, inserted: false };
  }

  if (a.eventCreated === cur.lastEventCreated) {
    if (a.status === cur.status) {
      // A replay is not complete while order.created is still missing.
      // Once ordering is known, re-run aggregation even though no row changes.
      const creationUnknown = cur.attemptCreatedAt == null && cur.status !== 'succeeded';
      return {
        outcome: creationUnknown ? 'deferred' : 'ignored',
        attempt: cur,
        inserted: false,
        deferReason: creationUnknown ? 'attempt_creation_unknown' : null,
      };
    }
    // Equal timestamps, different statuses: do not guess from arrival order
    await tx.outbox.insert({
      eventId: a.eventId,
      task: 'reconcile_attempt',
      clinkOrderId: a.clinkOrderId,
      // One key per ambiguity, not one per Order
      dedupeKey: `reconcile_attempt:${a.clinkOrderId}:${a.eventId}`,
    });
    return {
      outcome: 'deferred',
      attempt: cur,
      inserted: false,
      deferReason: 'status_timestamp_tie',
    };
  }

  if (TERMINAL.has(cur.status)) {
    return { outcome: 'ignored', attempt: cur, inserted: false }; // terminal never rolls back
  }

  const update = {
    status: a.status,
    lastEventCreated: a.eventCreated,
    failureCode: a.failureCode ?? null,
    failureMessage: a.failureMessage ?? null,
  };
  await tx.paymentAttempts.update(a.clinkOrderId, update);

  // Creation time still missing, so this attempt cannot be treated as latest
  const creationUnknown = cur.attemptCreatedAt == null && a.status !== 'succeeded';
  return {
    outcome: creationUnknown ? 'deferred' : 'applied',
    attempt: { ...cur, ...update },
    inserted: false,
    deferReason: creationUnknown ? 'attempt_creation_unknown' : null,
  };
}

async function recomputeMerchantOrder(tx, merchantOrder, eventId) {
  // Re-read inside the lock to work from a current snapshot
  const attempts = await tx.paymentAttempts.listByMerchantOrder(merchantOrder.id);

  const succeededAttempts = attempts.filter((x) => x.status === 'succeeded');
  const succeeded = succeededAttempts[0] ?? null;
  const unorderedAttempts = attempts.filter((x) => x.attemptCreatedAt == null);

  // A persisted pointer inferred from order.created cannot break a timestamp
  // tie. Only a pointer confirmed by a Session query can do that.
  const current = merchantOrder.currentClinkOrderId
    ? attempts.find((x) => x.clinkOrderId === merchantOrder.currentClinkOrderId)
    : null;
  if (merchantOrder.currentClinkOrderId && !current) {
    const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(
      merchantOrder.currentClinkOrderId,
    );
    return recordManualReview(tx, {
      dedupeKey:
        `current_attempt_ownership_conflict:${merchantOrder.id}:${merchantOrder.currentClinkOrderId}`,
      eventId,
      merchantOrderId: merchantOrder.id,
      clinkOrderId: merchantOrder.currentClinkOrderId,
      reason: 'current_attempt_pointer_ownership_conflict',
      evidence: {
        merchantOrderId: merchantOrder.id,
        currentClinkOrderId: merchantOrder.currentClinkOrderId,
        storedAttempt: attemptEvidence(storedAttempt),
      },
    });
  }

  // Sort on attemptCreatedAt only — never receivedAt, an autoincrement id, or orderId
  const ordered = attempts
    .filter((x) => x.attemptCreatedAt != null)
    .sort((a, b) => b.attemptCreatedAt - a.attemptCreatedAt);
  const latestIsTied =
    ordered.length > 1 &&
    ordered[0].attemptCreatedAt === ordered[1].attemptCreatedAt;

  const confirmedCurrent = merchantOrder.currentAttemptConfirmed ? current : null;
  let paymentStatus;
  let needsSessionReconciliation = false;
  if (succeededAttempts.length > 0) {
    paymentStatus = 'paid';                    // one success is enough
  } else if (confirmedCurrent) {
    // A fresh Session answer may select an Attempt whose order.created has not
    // arrived. The confirmed pointer is authoritative for the current Attempt.
    paymentStatus = ATTEMPT_TO_ORDER[confirmedCurrent.status] ?? 'pending';
  } else if (unorderedAttempts.length > 0) {
    paymentStatus = 'pending';
    needsSessionReconciliation = true;
  } else if (latestIsTied) {
    paymentStatus = 'pending';
    needsSessionReconciliation = true;
  } else if (ordered.length > 0) {
    paymentStatus = ATTEMPT_TO_ORDER[ordered[0].status] ?? 'pending';
  } else {
    paymentStatus = 'pending';
  }

  // Second line of defence: terminal states are only rewritten by the refund flow
  const merchantStatusIsTerminal =
    ['paid', 'refunded', 'partial_refunded'].includes(merchantOrder.paymentStatus);
  if (!merchantStatusIsTerminal && merchantOrder.paymentStatus !== paymentStatus) {
    await tx.merchantOrders.updateIfStatus(
      merchantOrder.id,
      merchantOrder.paymentStatus,             // conditional UPDATE, paired with the row lock
      { paymentStatus },
    );
    merchantOrder.paymentStatus = paymentStatus;
  }

  if (
    paymentStatus === 'paid' &&
    !['refunded', 'partial_refunded'].includes(merchantOrder.paymentStatus)
  ) {
    await tx.outbox.insert({
      eventId,
      task: 'fulfill',
      orderId: merchantOrder.id,
      clinkOrderId: succeeded.clinkOrderId,
      dedupeKey: `fulfill:${merchantOrder.id}`,   // unique; duplicate fulfillment stops here
    });
  }
  if (needsSessionReconciliation) {
    // One key per concrete ambiguity. Reprocessing the same Webhook is silent,
    // while a later new Order gets its own Session query.
    await tx.outbox.insert({
      eventId,
      task: 'reconcile_session',
      orderId: merchantOrder.id,
      dedupeKey: `reconcile_session:${merchantOrder.id}:${eventId}`,
    });
    return 'deferred';
  }
  return 'done';
}

function recordRefundReview(tx, context, reason, evidence) {
  const refundKey = context.refundId ?? `event:${context.eventId}`;
  return recordManualReview(tx, {
    dedupeKey: `refund_conflict:${refundKey}`,
    eventId: context.eventId,
    merchantOrderId: context.merchantOrderId,
    clinkOrderId: context.clinkOrderId,
    refundId: context.refundId ?? null,
    reason,
    evidence,
  });
}

function normalizeRefundCandidate(incoming, eventId) {
  const amount = tryPositiveDecimal(incoming.amount);
  const currency = tryNormalizeCurrency(incoming.currency);
  if (
    !incoming.refundId ||
    !incoming.merchantOrderId ||
    !incoming.clinkOrderId ||
    !amount ||
    !currency
  ) return null;
  return {
    refundId: incoming.refundId,
    merchantOrderId: incoming.merchantOrderId,
    clinkOrderId: incoming.clinkOrderId,
    amount: amount.toString(),
    currency,
    status: 'success',
    firstEventId: eventId,
  };
}

function isExactRefundReplay(stored, candidate) {
  const storedAmount = tryPositiveDecimal(stored?.amount);
  return Boolean(stored) &&
    stored.merchantOrderId === candidate.merchantOrderId &&
    stored.clinkOrderId === candidate.clinkOrderId &&
    storedAmount?.equals(new Decimal(candidate.amount)) === true &&
    tryNormalizeCurrency(stored.currency) === candidate.currency &&
    stored.status === candidate.status;
}

// INSERT ... ON CONFLICT DO NOTHING, then compare the immutable business
// tuple. A conflict never executes UPDATE and therefore never destroys proof.
async function persistRefundOnce(tx, incoming, context) {
  const candidate = normalizeRefundCandidate(incoming, context.eventId);
  if (!candidate) {
    return {
      outcome: await recordRefundReview(tx, context, 'invalid_refund_record', {
        incoming: refundEvidence(incoming),
      }),
      refund: null,
    };
  }

  const inserted = await tx.refunds.insertIfAbsent(candidate);
  const stored = inserted ?? await tx.refunds.findByRefundId(incoming.refundId);
  if (!stored) throw new Error(`refund ${incoming.refundId} disappeared after insert`);

  if (!isExactRefundReplay(stored, candidate)) {
    return {
      outcome: await recordRefundReview(tx, context, 'refund_id_immutable_fields_conflict', {
        stored: refundEvidence(stored),
        incoming: refundEvidence(candidate),
      }),
      refund: stored,
    };
  }
  return { outcome: inserted ? 'stored' : 'replayed', refund: stored };
}

async function handleRefund(event, obj, tx) {
  if (event.type !== 'refund.succeeded') return 'done';

  // Check an existing immutable refund before deferring on a missing attempt.
  // Reusing its refundId with an unknown/different Order is deterministic.
  const preexistingRefund = obj.refundId
    ? await tx.refunds.findByRefundId(obj.refundId)
    : null;

  // A refund event carries only orderId, so find the payment attempt first.
  const attempt = await tx.paymentAttempts.findByClinkOrderId(obj.orderId);
  if (!attempt) {
    if (!preexistingRefund) return 'deferred';

    const merchantOrder = await tx.merchantOrders.findByIdForUpdate(
      preexistingRefund.merchantOrderId,
    );
    if (!merchantOrder) throw new Error(`merchant order ${preexistingRefund.merchantOrderId} missing`);
    return recordRefundReview(tx, {
      eventId: event.id,
      merchantOrderId: merchantOrder.id,
      clinkOrderId: obj.orderId ?? null,
      refundId: obj.refundId,
    }, 'refund_id_reused_with_missing_attempt', {
      stored: refundEvidence(preexistingRefund),
      incoming: refundEvidence({
        refundId: obj.refundId,
        orderId: obj.orderId ?? null,
        refundAmount: obj.refundAmount ?? null,
        refundCurrency: obj.refundCurrency ?? null,
        status: 'success',
      }),
    });
  }

  // Same lock, same lock order as the order events — otherwise the two
  // paths deadlock against each other
  const merchantOrder = await tx.merchantOrders.findByIdForUpdate(attempt.merchantOrderId);
  if (!merchantOrder) return 'deferred';

  const reviewContext = {
    eventId: event.id,
    merchantOrderId: merchantOrder.id,
    clinkOrderId: attempt.clinkOrderId,
    refundId: obj.refundId,
  };
  const incomingRefund = {
    refundId: obj.refundId,
    merchantOrderId: merchantOrder.id,
    clinkOrderId: attempt.clinkOrderId,
    amount: obj.refundAmount,
    currency: obj.refundCurrency,
    status: 'success',
  };
  const candidate = normalizeRefundCandidate(incomingRefund, event.id);
  if (!candidate) {
    return recordRefundReview(tx, reviewContext, 'invalid_refund_record', {
      incoming: refundEvidence(incomingRefund),
    });
  }
  const storedRefund = await tx.refunds.findByRefundId(obj.refundId);
  if (storedRefund && !isExactRefundReplay(storedRefund, candidate)) {
    return recordRefundReview(tx, reviewContext, 'refund_id_immutable_fields_conflict', {
      stored: refundEvidence(storedRefund),
      incoming: refundEvidence(candidate),
    });
  }

  const basis = [
    merchantOrder.refundablePaidClinkOrderId,
    merchantOrder.refundablePaidAmount,
    merchantOrder.refundablePaidCurrency,
  ];
  if (basis.every((x) => x == null)) return 'deferred';
  if (basis.some((x) => x == null)) {
    return recordRefundReview(tx, reviewContext, 'stored_refund_basis_incomplete', {
      storedBasis: basis,
      incomingRefund: refundEvidence(incomingRefund),
    });
  }

  const paidCurrency = tryNormalizeCurrency(merchantOrder.refundablePaidCurrency);
  const refundCurrency = tryNormalizeCurrency(obj.refundCurrency);
  if (
    attempt.clinkOrderId !== merchantOrder.refundablePaidClinkOrderId ||
    !paidCurrency ||
    refundCurrency !== paidCurrency
  ) {
    return recordRefundReview(tx, reviewContext, 'refund_order_or_currency_conflict', {
      storedBasis: {
        clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
        amount: merchantOrder.refundablePaidAmount,
        currency: merchantOrder.refundablePaidCurrency,
      },
      storedRefund: refundEvidence(storedRefund),
      incomingRefund: refundEvidence(incomingRefund),
    });
  }

  const persisted = await persistRefundOnce(tx, incomingRefund, reviewContext);
  if (persisted.outcome === 'manual_review') return persisted.outcome;

  const refunds = await tx.refunds.listSuccessByMerchantOrder(merchantOrder.id);
  const invalid = refunds.find((x) => !tryPositiveDecimal(x.amount));
  const wrongOrder = refunds.find(
    (x) => x.clinkOrderId !== merchantOrder.refundablePaidClinkOrderId,
  );
  const wrongCurrency = refunds.find(
    (x) => tryNormalizeCurrency(x.currency) !== paidCurrency,
  );
  const conflicting = invalid ?? wrongOrder ?? wrongCurrency;
  if (conflicting) {
    return recordRefundReview(
      tx,
      { ...reviewContext, refundId: conflicting.refundId },
      'stored_successful_refund_conflict',
      { storedBasis: basis, conflictingRefund: refundEvidence(conflicting) },
    );
  }

  const refundedAmount = refunds.reduce(
    (sum, x) => sum.plus(tryPositiveDecimal(x.amount)),
    new Decimal(0),
  );
  const refundablePaidAmount = tryPositiveDecimal(merchantOrder.refundablePaidAmount);
  if (!refundablePaidAmount || refundedAmount.greaterThan(refundablePaidAmount)) {
    return recordRefundReview(tx, reviewContext, 'refund_total_exceeds_payment_basis', {
      refundablePaidAmount: merchantOrder.refundablePaidAmount,
      refundedAmount: refundedAmount.toString(),
      refundIds: refunds.map((x) => x.refundId),
    });
  }
  await tx.merchantOrders.update(merchantOrder.id, { refundedAmount: refundedAmount.toString() });

  // Do not write refunded here; the reconcile task settles the final status
  // from the Order query
  await tx.outbox.insert({
    eventId: event.id,
    task: 'refund_reconcile',
    orderId: merchantOrder.id,
    clinkOrderId: attempt.clinkOrderId,
    payload: { refundId: obj.refundId },
    dedupeKey: `refund_reconcile:${obj.refundId}`,
  });
  return 'done';
}
```

<Warning>
  **The `webhook_events` unique index does not stop concurrent writes to one order.** It guarantees a given `event.id` is handled once; `order.succeeded` and another Order's `order.failed` are two different events that can arrive together, each reading a stale snapshot and each writing it back.

  So take `SELECT ... FOR UPDATE` on the merchant order row before touching attempts, serializing every status update for that order. A status-conditional `UPDATE` is a useful second layer, but **it cannot replace the row lock** — it prevents an overwrite, not an aggregate computed from a stale snapshot.

  The refund branch uses the same lock in the same order; crossing lock orders between the two paths deadlocks.
</Warning>

<Note>
  **When ordering cannot be established, do not guess.** A succeeded Attempt is globally decisive. Otherwise, a Session-confirmed current Attempt may decide even while its `attemptCreatedAt` is null. Without either of those safe anchors:

  * Any Attempt whose `order.created` has not arrived makes the aggregate `pending`; queue `reconcile_session` as well as the event's `reprocess_webhook`, so `GET /checkout/session/{sessionId}` can identify the current Order without waiting forever for creation time
  * Two attempts sharing the same **known, non-null** `attemptCreatedAt` also queue `reconcile_session`

  A newly inserted unordered Order invalidates an older confirmed pointer. A later `order.created` only fills that Attempt's creation time and never rolls its status back.

  The same applies within one Order when two status events share an `event.created`: queue `reconcile_attempt` and converge from `GET /order/{orderId}`.

  If the Session answer remains unavailable, stay `pending` and escalate after the retry policy. **`received_at`, autoincrement ids, Webhook arrival order, and lexical ordering of Clink Order IDs must never break the tie** — none of them tracks real creation order.
</Note>

Keep the three responsibilities separate. Collapsing them is what produces the rollbacks described above:

| Layer | Enforced by | Governs |
| - | - | - |
| Event idempotency | `webhook_events.id` unique index + one transaction | One `event.id` produces side effects once |
| Single-attempt convergence | `last_event_created` comparison + terminal states | A given `clink_order_id` only moves forward |
| Merchant order aggregation | Scanning every attempt on the order | Which of several Orders decides the outcome |

<Warning>
  **`order.next_action` speaks only for the current attempt.** A superseded Order must not write it back to the merchant order — in `recomputeMerchantOrder` above, `action_required` comes only from the Session-confirmed `current`, or from `ordered[0]` when creation time has a unique latest value. An unresolved tie stays `pending`.

  By the same token, one failed attempt **is not a failed order**. If any attempt succeeded, the aggregate is success.
</Warning>

<Note>
  **When ordering or fields cannot settle it, read the API.** `event.created` only orders events *within one Order*. If two events share a timestamp, or no reliable version field is available, call `GET /order/{orderId}` for that `obj.orderId` and converge the attempt from the returned `status` — **a server-side read is the soundest way to resolve ordering ambiguity**, not the push order and certainly not frontend events.

  Do that lookup from an outbox task, not synchronously inside the webhook request.
</Note>

Put a unique index on `dedupeKey` so queuing the same piece of work twice is rejected by the database rather than piling up duplicate tasks.

<Warning>
  **`refund.succeeded` does not mean a full refund.** A successful partial refund fires the same event, so setting `refunded` unconditionally marks partly-refunded orders as fully refunded.

  **Do not re-read the Order the moment the event arrives, either.** The refund service updates the refund record and emits the event first, then updates the Order status asynchronously. A lookup at that instant will often still return the pre-refund status.

  Also note this event means **the refund succeeded on Clink's side**, not that the money is back in the customer's account. A card refund typically takes several more business days, so support messaging should not say "funds received".
</Warning>

### The outbox worker

The webhook only records state and queues a to-do, both in one transaction. A separate worker does the actual work.

Claiming and executing must be separate: **claim in a short transaction, then make external calls after it commits**. Holding a row lock across a slow external API drains the connection pool.

Here is the full table. Every default is supplied, so an `INSERT` only has to carry business fields:

```sql theme={null}
CREATE TABLE outbox (
  id             BIGSERIAL PRIMARY KEY,
  task           TEXT        NOT NULL
                 CHECK (task IN (
                   'fulfill', 'refund_reconcile', 'reprocess_webhook',
                   'reconcile_attempt', 'reconcile_session',
                   'apply_refund_policy', 'manual_reconciliation',
                   'page_outbox_failure'
                 )),
  event_id       TEXT        NOT NULL,
  order_id       TEXT,                   -- merchant order number, read by the worker
  clink_order_id TEXT,                   -- Clink Order ID, read by refund reconciliation
  payload        JSONB,                  -- task arguments
  dedupe_key     TEXT        NOT NULL,   -- stops the same work being queued twice
  status         TEXT        NOT NULL DEFAULT 'pending'
                 CHECK (status IN ('pending', 'processing', 'succeeded', 'failed')),
  attempt        INT         NOT NULL DEFAULT 0,
  next_retry_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  claim_token    TEXT,                   -- a fresh random value on every claim
  worker_id      TEXT,
  lease_until    TIMESTAMPTZ,
  last_error     TEXT,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- The claim query runs on this index
CREATE INDEX idx_outbox_claim ON outbox (status, next_retry_at);

-- One row per piece of work
CREATE UNIQUE INDEX uq_outbox_dedupe ON outbox (dedupe_key);
```

Queuing a task writes only business fields; `status`, `attempt`, and `next_retry_at` come from the defaults:

```sql theme={null}
INSERT INTO outbox
  (task, event_id, order_id, clink_order_id, payload, dedupe_key)
VALUES
  ($1, $2, $3, $4, $5::jsonb, $6)
ON CONFLICT (dedupe_key) DO NOTHING;
```

<Note>
  `ON CONFLICT DO NOTHING` turns a duplicate insert into a silent skip rather than an exception. That matters inside the webhook transaction — queuing the same work twice should not roll the whole thing back.
</Note>

<Note>
  Neither reconcile task needs extra columns:

  * `reconcile_attempt` calls `GET /order/{clinkOrderId}` using `clink_order_id`
  * `reconcile_session` looks the merchant order up by `order_id` and takes `clink_session_id` from that row
  * `refund_reconcile` takes `refundId` from `payload`; it reads the latest cumulative `refundedAmount` again under the merchant-order lock
  * `apply_refund_policy` takes `refundId`, `orderStatus`, `refundedAmount`, and `paymentCurrency` from `payload`
  * `manual_reconciliation` takes a stable `caseKey` from `payload`, loads the immutable evidence row, and opens or updates the operations case and alert idempotently on that key
  * `page_outbox_failure` carries only the failed task ID/type and allowlisted error name/code; it retries paging durably and never creates another paging task

  If a task later needs more arguments — a retry policy, an expected status — put them in the `payload` JSONB column rather than adding a column per task type.
</Note>

```javascript theme={null}
import crypto from 'node:crypto';
import Decimal from 'decimal.js';
import {
  db,
  fulfill,
  applyRefundPolicy,
  reconciliationDesk,
  alerting,
  logger,
} from './merchant-adapters.js';

const LEASE_MS = 5 * 60 * 1000;   // Lease length; leave room for the slowest external call
const HEARTBEAT_MS = Math.floor(LEASE_MS / 3);
const MAX_ATTEMPT = 12;
const MAX_BACKOFF_MS = 60 * 60 * 1000;

function backoff(attempt, { now = Date.now(), random = Math.random } = {}) {
  const numericAttempt = Number(attempt);
  const normalizedAttempt = Number.isFinite(numericAttempt)
    ? Math.max(1, Math.floor(numericAttempt))
    : 1;
  const exponential = Math.min(
    MAX_BACKOFF_MS,
    1000 * (2 ** Math.min(normalizedAttempt - 1, 30)),
  );
  const randomValue = Math.min(1, Math.max(0, Number(random()) || 0));
  const jittered = Math.min(
    MAX_BACKOFF_MS,
    Math.round(exponential * (0.75 + randomValue * 0.5)),
  );
  return new Date(Number(now) + jittered);
}

// Step 1: claim exactly one task atomically. A worker never lets a batch wait
// behind the first slow task while every lease counts down from the same instant.
async function claimTask(workerId) {
  // A new token on every claim — never reuse the old one
  const claimToken = crypto.randomUUID();
  const { rows } = await db.query(
    `UPDATE outbox SET
       status      = 'processing',
       claim_token = $1,
       worker_id   = $2,
       lease_until = NOW() + ($3 || ' milliseconds')::interval
     WHERE id IN (
       SELECT id FROM outbox
       WHERE (status = 'pending'    AND next_retry_at <= NOW())
          OR (status = 'processing' AND lease_until   <  NOW())   -- expired lease, reclaim
       ORDER BY id
       LIMIT 1
       FOR UPDATE SKIP LOCKED
     )
     RETURNING
       id,
       task,
       event_id       AS "eventId",
       order_id       AS "orderId",
       clink_order_id AS "clinkOrderId",
       payload,
       dedupe_key     AS "dedupeKey",
       status,
       attempt,
       next_retry_at  AS "nextRetryAt",
       claim_token    AS "claimToken",
       worker_id      AS "workerId",
       lease_until    AS "leaseUntil",
       last_error     AS "lastError",
       created_at     AS "createdAt",
       updated_at     AS "updatedAt"`,
    [claimToken, workerId, LEASE_MS]
  );
  return rows[0] ?? null;
}

function startLeaseHeartbeat(t) {
  let leaseLost = false;
  let stopped = false;
  let renewal = Promise.resolve(true);
  const controller = new AbortController();

  function markLeaseLost(reason) {
    if (leaseLost) return;
    leaseLost = true;
    controller.abort(reason);
    logger.warn({ taskId: t.id }, 'lease lost, stop claimed task');
  }

  function ensureOwned() {
    renewal = renewal.then(async () => {
      if (stopped || leaseLost) return false;
      try {
        const owned = await renewLease(t);
        if (!owned) markLeaseLost(new Error('outbox lease lost'));
        return owned;
      } catch (err) {
        // A renewal error means ownership is unknown. Stop rather than risk a
        // side effect under an expired token.
        markLeaseLost(err);
        return false;
      }
    });
    return renewal;
  }

  const timer = setInterval(() => { void ensureOwned(); }, HEARTBEAT_MS);
  timer.unref?.();

  return {
    signal: controller.signal,
    isLost: () => leaseLost,
    ensureOwned,
    async stop() {
      stopped = true;
      clearInterval(timer);
      await renewal;
    },
  };
}

function throwIfAborted(signal) {
  if (signal?.aborted) throw signal.reason ?? new Error('operation aborted');
}

async function runOutboxWorker(workerId) {
  // The claim statement commits here; the row lock is released with it.
  const t = await claimTask(workerId);
  if (!t) return;

  // A task may have waited between claim and execution. Confirm ownership
  // before starting any API call or external side effect.
  if (!await renewLease(t)) {
    logger.warn({ taskId: t.id }, 'lease lost before task start');
    return;
  }

  const lease = startLeaseHeartbeat(t);
  try {
    await executeClaimedTask(t, lease);
    if (lease.isLost() || !await lease.ensureOwned()) return;
    await finishTask(t, 'succeeded');
  } catch (err) {
    if (lease.isLost() || !await lease.ensureOwned()) return;
    await failTask(t, err);
  } finally {
    await lease.stop();
  }
}

// Step 2: external calls happen outside row-lock transactions. Every adapter
// receives its stable business idempotency key and AbortSignal where supported.
async function executeClaimedTask(t, lease, adapters = {
  fulfill,
  reconcileRefund,
  applyRefundPolicy,
  reprocessWebhook,
  reconcileAttempt,
  reconcileSession,
  reconciliationDesk,
  alerting,
  findReconciliationCase: (caseKey) =>
    db.reconciliationCases.findByDedupeKey(caseKey),
}) {
  if (t.task === 'fulfill') {
    await adapters.fulfill(t.orderId, {
      eventId: t.eventId,
      idempotencyKey: t.orderId,
      signal: lease.signal,
    });
  } else if (t.task === 'refund_reconcile') {
    await adapters.reconcileRefund(t.orderId, t.clinkOrderId, t.eventId, t.payload, {
      signal: lease.signal,
    });
  } else if (t.task === 'apply_refund_policy') {
    // Deduplicate on refundId, and only advance the policy's stored cumulative
    // amount so an older task cannot roll entitlement backward.
    await adapters.applyRefundPolicy({
      merchantOrderId: t.orderId,
      refundId: t.payload.refundId,
      orderStatus: t.payload.orderStatus,
      refundedAmount: t.payload.refundedAmount,
      paymentCurrency: t.payload.paymentCurrency,
      idempotencyKey: t.payload.refundId,
      signal: lease.signal,
    });
  } else if (t.task === 'manual_reconciliation') {
    const review = await adapters.findReconciliationCase(t.payload.caseKey);
    if (!review) throw new Error(`manual-review case ${t.payload.caseKey} missing`);

    const reviewContext = {
      caseKey: review.dedupeKey,
      eventId: review.eventId,
      merchantOrderId: review.merchantOrderId,
      clinkOrderId: review.clinkOrderId ?? null,
      refundId: review.refundId ?? null,
      reason: review.reason,
      evidence: review.evidence,
    };
    await adapters.reconciliationDesk.openOrUpdate({
      ...reviewContext,
      externalId: t.payload.caseKey,
      signal: lease.signal,
    });
    // Desk and paging are separate external steps. Re-check the token so a
    // lost worker never starts the second step.
    if (!await lease.ensureOwned()) return;
    await adapters.alerting.openOrUpdate({
      dedupeKey: t.payload.caseKey,
      title: 'payment data requires manual reconciliation',
      context: reviewContext,
      signal: lease.signal,
    });
  } else if (t.task === 'reprocess_webhook') {
    await adapters.reprocessWebhook(t.eventId, { signal: lease.signal });
  } else if (t.task === 'reconcile_attempt') {
    await adapters.reconcileAttempt(t.clinkOrderId, t.eventId, { signal: lease.signal });
  } else if (t.task === 'reconcile_session') {
    await adapters.reconcileSession(t.orderId, t.eventId, { signal: lease.signal });
  } else if (t.task === 'page_outbox_failure') {
    await adapters.alerting.openOrUpdate({
      dedupeKey: t.dedupeKey,
      title: 'outbox task exhausted',
      context: {
        failedTaskId: t.payload.failedTaskId,
        failedTask: t.payload.failedTask,
        errorName: t.payload.errorName,
        errorCode: t.payload.errorCode,
      },
      signal: lease.signal,
    });
  } else {
    // Never mark an unimplemented task succeeded.
    throw new Error(`unknown outbox task: ${t.task}`);
  }
}

// Raw SQL is snake_case, but claimTask aliases every returned task property to
// camelCase. Worker code must use that one shape exclusively.
// Step 3: every status update must carry claim_token and status='processing'
async function finishTask(t, status) {
  const { rowCount } = await db.query(
    `UPDATE outbox SET
       status = $1, claim_token = NULL, worker_id = NULL, lease_until = NULL
     WHERE id = $2 AND claim_token = $3 AND status = 'processing'`,
    [status, t.id, t.claimToken]
  );
  if (rowCount === 0) {
    // The lease expired and another worker owns this task now. Let go
    logger.warn({ taskId: t.id }, 'lease lost, result discarded');
  }
  return rowCount;
}

async function failTask(t, err) {
  const attempt = t.attempt + 1;
  // Paging failures retry forever (with a capped delay) and never recursively
  // create another page_outbox_failure task.
  const exhausted = t.task !== 'page_outbox_failure' && attempt >= MAX_ATTEMPT;
  const errorName = typeof err?.name === 'string' ? err.name.slice(0, 100) : 'Error';
  const errorCode = ['string', 'number'].includes(typeof err?.code)
    ? String(err.code).slice(0, 100)
    : null;
  const safeLastError = errorCode ? `${errorName}:${errorCode}` : errorName;

  if (!exhausted) {
    const { rowCount } = await db.query(
      `UPDATE outbox SET
         status = 'pending', claim_token = NULL, worker_id = NULL, lease_until = NULL,
         attempt = $1, next_retry_at = $2, last_error = $3
       WHERE id = $4 AND claim_token = $5 AND status = 'processing'`,
      [attempt, backoff(attempt), safeLastError, t.id, t.claimToken]
    );
    if (rowCount === 0) logger.warn({ taskId: t.id }, 'lease lost, result discarded');
    return rowCount;
  }

  // Marking the original task failed and creating its paging task is one
  // transaction. A crash after commit still leaves durable work to claim.
  const rowCount = await db.transaction(async (tx) => {
    const result = await tx.query(
      `UPDATE outbox SET
         status = 'failed', claim_token = NULL, worker_id = NULL, lease_until = NULL,
         attempt = $1, next_retry_at = $2, last_error = $3
       WHERE id = $4 AND claim_token = $5 AND status = 'processing'`,
      [attempt, backoff(attempt), safeLastError, t.id, t.claimToken]
    );
    if (result.rowCount === 0) return 0;

    await tx.outbox.insert({
      eventId: t.eventId,
      task: 'page_outbox_failure',
      payload: {
        failedTaskId: t.id,
        failedTask: t.task,
        errorName,
        errorCode,
      },
      dedupeKey: `outbox_failure_alert:${t.id}`,
    });
    return result.rowCount;
  });
  if (rowCount === 0) logger.warn({ taskId: t.id }, 'lease lost, result discarded');
  return rowCount;
}

// Renewal carries the token too, or it would extend someone else's lease
async function renewLease(t) {
  const { rowCount } = await db.query(
    `UPDATE outbox SET lease_until = NOW() + ($1 || ' milliseconds')::interval
     WHERE id = $2 AND claim_token = $3 AND status = 'processing'`,
    [LEASE_MS, t.id, t.claimToken]
  );
  return rowCount === 1;   // false means the lease is gone — stop work immediately
}

// Retry an event that was deferred until its dependency existed
async function reprocessWebhook(eventId, { signal } = {}) {
  throwIfAborted(signal);
  const outcome = await db.transaction(async (tx) => {
    const row = await tx.webhookEvents.findById(eventId);
    if (!row || row.status === 'processed') return 'done';

    const outcome = await handleEvent(JSON.parse(row.payload), tx);
    if (outcome === 'done' || outcome === 'manual_review') {
      await tx.webhookEvents.update(eventId, { status: 'processed' });
    }
    throwIfAborted(signal);   // abort rolls the transaction back before commit
    return outcome;
  });

  // Throw only after the transaction commits. Any reconciliation task queued
  // by handleEvent stays durable while this task goes through backoff.
  if (outcome === 'deferred') throw new Error('dependency still missing');
}

// Two status events on one Order shared a timestamp — converge from the API
async function reconcileAttempt(clinkOrderId, eventId, { signal } = {}) {
  throwIfAborted(signal);
  // Capture the local version before the external query.
  const snapshot = await db.paymentAttempts.findByClinkOrderId(clinkOrderId);
  if (!snapshot) throw new Error(`attempt ${clinkOrderId} missing`);

  // Query first, then open the transaction. External calls never run under a lock.
  const order = await clinkGet(`/order/${clinkOrderId}`, { signal });
  throwIfAborted(signal);

  const outcome = await db.transaction(async (tx) => {
    // Same lock, same order as the webhook handler. Re-read the attempt only
    // after taking this lock, because every local attempt write takes it first.
    const merchantOrder = await tx.merchantOrders.findByIdForUpdate(snapshot.merchantOrderId);
    if (!merchantOrder) throw new Error(`merchant order ${snapshot.merchantOrderId} missing`);

    const attempt = await tx.paymentAttempts.findByClinkOrderId(clinkOrderId);
    if (
      !attempt ||
      merchantOrder.clinkSessionId !== snapshot.clinkSessionId ||
      attempt.merchantOrderId !== snapshot.merchantOrderId ||
      attempt.clinkSessionId !== snapshot.clinkSessionId ||
      attempt.status !== snapshot.status ||
      attempt.lastEventCreated !== snapshot.lastEventCreated ||
      attempt.attemptCreatedAt !== snapshot.attemptCreatedAt
    ) {
      // Local state advanced while Clink was being queried. Roll back and let
      // the worker retry from a fresh snapshot; never apply this stale result.
      throw new Error(`attempt ${clinkOrderId} changed during reconciliation`);
    }

    if (
      order.orderId !== clinkOrderId ||
      order.sessionId !== attempt.clinkSessionId ||
      order.merchantReferenceId !== merchantOrder.id
    ) {
      const manualOutcome = await recordManualReview(tx, {
        dedupeKey: `order_query_ownership_conflict:${merchantOrder.id}:${clinkOrderId}`,
        eventId,
        merchantOrderId: merchantOrder.id,
        clinkOrderId,
        reason: 'order_query_ownership_conflict',
        evidence: {
          requestedOrderId: clinkOrderId,
          returnedOrder: orderEvidence(order),
          localAttempt: attemptEvidence(attempt),
          storedBasis: {
            clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
            amount: merchantOrder.refundablePaidAmount,
            currency: merchantOrder.refundablePaidCurrency,
          },
        },
      });
      await tx.webhookEvents.update(eventId, { status: 'processed' });
      return manualOutcome;
    }

    // An already-refunded order takes the refund path, never the
    // "paid -> queue fulfillment" one
    let aggregateOutcome = 'done';
    if (REFUND_STATUS.has(order.status)) {
      await tx.paymentAttempts.update(clinkOrderId, {
        status: 'succeeded',
        failureCode: null,
        failureMessage: null,
      });
    } else {
      const mapped = ORDER_STATUS_TO_ATTEMPT[order.status];
      if (!mapped) {
        // Still pending, or an unknown value — not settled yet
        throw new Error(`order ${clinkOrderId} not settled yet: ${order.status}`);
      }
      await tx.paymentAttempts.update(clinkOrderId, {
        status: mapped,
        failureCode: mapped === 'failed' ? order.failureCode : null,
        failureMessage: mapped === 'failed' ? order.failureMessage : null,
      });
    }

    if (order.status === 'success' || REFUND_STATUS.has(order.status)) {
      // GET /order exposes amountTotal and paymentCurrency for Hosted Checkout,
      // so this path can establish the same basis as order.succeeded. The
      // attempt update above remains durable if the basis is quarantined.
      const basisOutcome = await persistRefundablePaymentBasis(
        tx,
        merchantOrder,
        {
          clinkOrderId,
          amountTotal: order.amountTotal,
          paymentCurrency: order.paymentCurrency,
        },
        { eventId },
      );
      if (basisOutcome === 'manual_review') {
        await tx.webhookEvents.update(eventId, { status: 'processed' });
        return basisOutcome;
      }
    }

    if (REFUND_STATUS.has(order.status)) {
      await advanceRefundStatus(tx, merchantOrder, order.status);
    } else {
      aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, eventId);
    }

    // Close the event only after both the attempt and merchant-order aggregate
    // have converged. A Session tie remains pending.
    if (aggregateOutcome === 'done' || aggregateOutcome === 'manual_review') {
      await tx.webhookEvents.update(eventId, { status: 'processed' });
    }
    return aggregateOutcome;
  });

  if (outcome === 'deferred') throw new Error('merchant order still needs Session reconciliation');
}

const ORDER_STATUS_TO_ATTEMPT = {
  success:         'succeeded',
  failed:          'failed',
  requires_action: 'action_required',
};

// Refund states are handled separately and never feed the payment aggregation
const REFUND_STATUS = new Set(['partial_refunded', 'refunded']);

// Refund state is monotonic: refunded is never overwritten by partial_refunded
const REFUND_RANK = { partial_refunded: 1, refunded: 2 };

async function advanceRefundStatus(tx, merchantOrder, orderStatus) {
  const curRank = REFUND_RANK[merchantOrder.paymentStatus] ?? 0;
  if (REFUND_RANK[orderStatus] <= curRank) return;    // forward only

  await tx.merchantOrders.updateIfStatus(
    merchantOrder.id,
    merchantOrder.paymentStatus,
    { paymentStatus: orderStatus },
  );
}

async function queueRefundPolicy(tx, merchantOrder, refund) {
  // Queue once per successful refund, even when the Order remains
  // partial_refunded. State rank and policy evaluation are separate concerns.
  await tx.outbox.insert({
    eventId: refund.eventId,
    task: 'apply_refund_policy',
    orderId: merchantOrder.id,
    payload: {
      refundId: refund.refundId,
      orderStatus: refund.orderStatus,
      refundedAmount: refund.refundedAmount,
      paymentCurrency: refund.paymentCurrency,
    },
    dedupeKey: `apply_refund_policy:${refund.refundId}`,
  });
}

// Stable comparison only. Lexical Order ID sorting here does not choose the
// current attempt; it merely makes identical local snapshots hash the same way.
function fingerprintAttempts(attempts) {
  return attempts
    .map((x) => JSON.stringify([
      x.clinkOrderId,
      x.merchantOrderId,
      x.clinkSessionId,
      x.status,
      x.attemptCreatedAt ?? null,
      x.lastEventCreated,
    ]))
    .sort()
    .join('|');
}

// Two attempts share an attemptCreatedAt — ask the Session which one is current
async function reconcileSession(merchantOrderId, eventId, { signal } = {}) {
  throwIfAborted(signal);
  const local = await db.merchantOrders.findById(merchantOrderId);
  if (!local) throw new Error(`merchant order ${merchantOrderId} missing`);

  const localAttempts = await db.paymentAttempts.listByMerchantOrder(merchantOrderId);
  const snapshot = {
    clinkSessionId: local.clinkSessionId,
    currentClinkOrderId: local.currentClinkOrderId,
    currentAttemptConfirmed: local.currentAttemptConfirmed,
    attemptsFingerprint: fingerprintAttempts(localAttempts),
  };

  const session = await clinkGet(`/checkout/session/${snapshot.clinkSessionId}`, { signal });
  throwIfAborted(signal);

  await db.transaction(async (tx) => {
    const merchantOrder = await tx.merchantOrders.findByIdForUpdate(merchantOrderId);
    if (!merchantOrder) throw new Error(`merchant order ${merchantOrderId} missing`);

    const lockedAttempts = await tx.paymentAttempts.listByMerchantOrder(merchantOrderId);
    if (
      merchantOrder.clinkSessionId !== snapshot.clinkSessionId ||
      merchantOrder.currentClinkOrderId !== snapshot.currentClinkOrderId ||
      merchantOrder.currentAttemptConfirmed !== snapshot.currentAttemptConfirmed ||
      fingerprintAttempts(lockedAttempts) !== snapshot.attemptsFingerprint
    ) {
      // Another Order event or reconciliation changed local truth after the
      // query began. Discard the stale Session answer and retry.
      throw new Error(`attempt set for ${merchantOrderId} changed during Session reconciliation`);
    }

    if (
      session.sessionId !== snapshot.clinkSessionId ||
      session.merchantReferenceId !== merchantOrder.id
    ) {
      const manualOutcome = await recordManualReview(tx, {
        dedupeKey: `session_response_conflict:${merchantOrderId}:${snapshot.clinkSessionId}`,
        eventId,
        merchantOrderId,
        clinkOrderId: session.orderId ?? null,
        reason: 'session_response_ownership_conflict',
        evidence: {
          querySnapshot: snapshot,
          returnedSession: sessionEvidence(session),
        },
      });
      await tx.webhookEvents.update(eventId, { status: 'processed' });
      return manualOutcome;
    }

    if (!session.orderId) {
      // No Order yet, or the state is unsettled — back off and retry.
      // Never treat this as success.
      throw new Error(`session ${snapshot.clinkSessionId} has no current orderId yet`);
    }

    // Read by global Order ID inside the lock. Missing is retryable; an Order
    // already owned by another merchant/session is a durable manual review.
    const attempt = await tx.paymentAttempts.findByClinkOrderId(session.orderId);
    if (!attempt) {
      throw new Error(`session order ${session.orderId} not persisted for ${merchantOrderId}`);
    }
    if (
      attempt.merchantOrderId !== merchantOrderId ||
      attempt.clinkSessionId !== snapshot.clinkSessionId
    ) {
      const manualOutcome = await recordManualReview(tx, {
        dedupeKey: `session_order_conflict:${merchantOrderId}:${session.orderId}`,
        eventId,
        merchantOrderId,
        clinkOrderId: session.orderId,
        reason: 'session_order_ownership_conflict',
        evidence: {
          querySnapshot: snapshot,
          returnedSession: sessionEvidence(session),
          storedAttempt: attemptEvidence(attempt),
          storedBasis: {
            clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
            amount: merchantOrder.refundablePaidAmount,
            currency: merchantOrder.refundablePaidCurrency,
          },
        },
      });
      await tx.webhookEvents.update(eventId, { status: 'processed' });
      return manualOutcome;
    }

    // Persist both the Session answer and its provenance. Only this confirmed
    // pointer may break an attemptCreatedAt tie.
    await tx.merchantOrders.update(merchantOrderId, {
      currentClinkOrderId: session.orderId,
      currentAttemptConfirmed: true,
    });
    merchantOrder.currentClinkOrderId = session.orderId;
    merchantOrder.currentAttemptConfirmed = true;

    const aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, eventId);
    if (aggregateOutcome === 'deferred') {
      throw new Error(`session ${snapshot.clinkSessionId} did not resolve the current attempt`);
    }
    await tx.webhookEvents.update(eventId, { status: 'processed' });
    return aggregateOutcome;
  });
}

async function reconcileRefund(
  merchantOrderId,
  clinkOrderId,
  eventId,
  refund,
  { signal } = {},
) {
  throwIfAborted(signal);
  if (!refund?.refundId) {
    throw new Error('refund reconciliation payload is incomplete');
  }

  // The query runs outside the transaction
  const order = await clinkGet(`/order/${clinkOrderId}`, { signal });
  throwIfAborted(signal);

  // Only once a result is in hand: open the transaction and take the same
  // lock, in the same order, as every other path
  await db.transaction(async (tx) => {
    const merchantOrder = await tx.merchantOrders.findByIdForUpdate(merchantOrderId);
    if (!merchantOrder) throw new Error(`merchant order ${merchantOrderId} missing`);

    const reviewContext = { eventId, merchantOrderId, clinkOrderId, refundId: refund.refundId };
    const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(clinkOrderId);
    if (
      !storedAttempt ||
      storedAttempt.merchantOrderId !== merchantOrder.id ||
      storedAttempt.clinkSessionId !== merchantOrder.clinkSessionId ||
      order.orderId !== clinkOrderId ||
      order.sessionId !== merchantOrder.clinkSessionId ||
      order.merchantReferenceId !== merchantOrder.id
    ) {
      const storedRefund = await tx.refunds.findByRefundId(refund.refundId);
      return recordRefundReview(tx, reviewContext, 'refund_order_query_ownership_conflict', {
        requestedOrderId: clinkOrderId,
        returnedOrder: orderEvidence(order),
        storedAttempt: attemptEvidence(storedAttempt),
        storedRefund: refundEvidence(storedRefund),
        storedBasis: {
          clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
          amount: merchantOrder.refundablePaidAmount,
          currency: merchantOrder.refundablePaidCurrency,
        },
      });
    }

    // Ownership is valid but the refund state has not settled yet. Back off
    // instead of reading a pre-refund status as a completed reconciliation.
    if (!REFUND_STATUS.has(order.status)) {
      throw new Error('order status not settled yet');
    }

    const basisOutcome = await persistRefundablePaymentBasis(
      tx,
      merchantOrder,
      {
        clinkOrderId,
        amountTotal: order.amountTotal,
        paymentCurrency: order.paymentCurrency,
      },
      {
        eventId,
        refundId: refund.refundId,
        dedupeKey: `refund_conflict:${refund.refundId}`,
      },
    );
    if (basisOutcome === 'manual_review') return basisOutcome;

    if (
      merchantOrder.refundablePaidClinkOrderId == null ||
      merchantOrder.refundablePaidAmount == null ||
      merchantOrder.refundablePaidCurrency == null
    ) {
      return recordRefundReview(tx, reviewContext, 'refund_payment_basis_missing', {
        storedBasis: {
          clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
          amount: merchantOrder.refundablePaidAmount,
          currency: merchantOrder.refundablePaidCurrency,
        },
      });
    }
    if (merchantOrder.refundablePaidClinkOrderId !== clinkOrderId) {
      return recordRefundReview(tx, reviewContext, 'refund_reconciliation_order_conflict', {
        storedOrderId: merchantOrder.refundablePaidClinkOrderId,
        incomingOrderId: clinkOrderId,
      });
    }

    // Compute the minimum state the Order API must have reached from the
    // latest locked totals. Decimal avoids rounding a full refund down into a
    // partial one. A lagging API result must retry; there is no later
    // order.refunded event that can repair a premature success.
    const refundedAmount = tryPositiveDecimal(merchantOrder.refundedAmount);
    const refundablePaidAmount = tryPositiveDecimal(merchantOrder.refundablePaidAmount);
    const paymentCurrency = tryNormalizeCurrency(merchantOrder.refundablePaidCurrency);
    if (!refundedAmount || !refundablePaidAmount || !paymentCurrency) {
      return recordRefundReview(tx, reviewContext, 'invalid_locked_refund_totals', {
        refundedAmount: merchantOrder.refundedAmount,
        refundablePaidAmount: merchantOrder.refundablePaidAmount,
        paymentCurrency: merchantOrder.refundablePaidCurrency,
      });
    }
    if (refundedAmount.greaterThan(refundablePaidAmount)) {
      return recordRefundReview(tx, reviewContext, 'refund_total_exceeds_payment_basis', {
        refundedAmount: refundedAmount.toString(),
        refundablePaidAmount: refundablePaidAmount.toString(),
      });
    }
    const expectedStatus = refundedAmount.greaterThanOrEqualTo(refundablePaidAmount)
      ? 'refunded'
      : 'partial_refunded';

    if (REFUND_RANK[order.status] < REFUND_RANK[expectedStatus]) {
      throw new Error('order refund status has not caught up yet');
    }

    // Status advances only when its rank increases, but every refundId gets a
    // policy task so repeated partial refunds are never collapsed together.
    await advanceRefundStatus(tx, merchantOrder, order.status);
    await queueRefundPolicy(tx, merchantOrder, {
      eventId,
      refundId: refund.refundId,
      orderStatus: order.status,
      // Use the latest value read under the lock, never the task's old snapshot.
      refundedAmount: refundedAmount.toString(),
      paymentCurrency,
    });
  });
}
```

A task moves through these states:

```
pending ──claim (new token)──> processing ──success──> succeeded
   ↑                               │
   ├──failure (under the cap)──────┤
   │                               └──cap reached──> failed + pending page task
   └──lease expiry, reclaimed by another worker
```

<Warning>
  **Retry exhaustion and paging are committed atomically.** The guarded update that marks the original task `failed` and the deduplicated `page_outbox_failure` insert run in one database transaction. The paging payload contains only `failedTaskId`, `failedTask`, `errorName`, and `errorCode`; it never copies an exception message, Webhook/API object, HTTP body, customer data, or payment details.

  `page_outbox_failure` awaits the alert adapter and becomes `succeeded` only after delivery. If paging fails, that task returns to `pending` with capped backoff; it never creates another paging task. The alert adapter uses `outbox_failure_alert:{failedTaskId}` as its external idempotency key.

  Infrastructure monitoring is still required for any `status = 'failed'` task, tasks left `pending`/`processing` too long, and `page_outbox_failure` rows that remain unsuccessful. Durable retry prevents a process crash from silently dropping the page; it does not replace queue health monitoring.
</Warning>

<Warning>
  **`SKIP LOCKED` is not an idempotency guarantee.** All it does is let concurrent `SELECT`s step over rows another transaction has locked, so they do not block each other.

  What actually stops two workers claiming the same task is **flipping the status to `processing` and writing a fresh `claim_token` in the same statement**. The `UPDATE ... RETURNING` above locks and marks in one step, leaving no window for another worker.

  Splitting it into `SELECT ... FOR UPDATE SKIP LOCKED`, commit, then `UPDATE` reopens that window. If it has to be two steps, they must sit inside the **same short transaction**.
</Warning>

<Warning>
  **Every status update must carry the `claim_token`.** This is what prevents a stale worker overwriting state after its lease expired:

  1. Worker A claims the task and holds `tokenA`
  2. A stalls; the lease expires
  3. Worker B reclaims the same task with `tokenB`
  4. A comes back and reports success — its `WHERE claim_token = tokenA` matches nothing, so the **row count is 0**
  5. B finishes normally, and its status is not overwritten by A

  A row count of 0 means "my lease is gone". At that point **log it and stop — do not retry the write, do not change the status**. Success, failure, and renewal updates all need this condition.

  Reclaiming **must generate a new token**; reusing the old one defeats the whole check.
</Warning>

<Note>
  **When a worker crashes.** If the process dies after claiming but before updating the status, the task is stuck in `processing` forever.

  That is what `lease_until` is for: the claim condition includes `status = 'processing' AND lease_until < NOW()`, so expired leases are picked up by the next worker.

  This sample claims **one task at a time**. It renews once before starting any side effect, then runs a heartbeat every `LEASE_MS / 3`. A failed renewal marks the local lease lost, aborts HTTP adapters that accept `AbortSignal`, suppresses all later steps and skips `finishTask` / `failTask`. The manual-reconciliation path renews again between opening the desk case and paging the alert.

  Business idempotency remains mandatory, but it is a final safety net — never a reason to knowingly start work after ownership is gone.

  `worker_id` and `lease_until` also serve diagnosis, showing which host is stuck and for how long.
</Note>

<Note>
  **Both reconcile tasks must actually issue their query — never no-op.**

  `reconcile_attempt` calls `GET /order/{clinkOrderId}` and maps the returned `status` onto an attempt status; `reconcile_session` calls `GET /checkout/session/{sessionId}` and treats the returned `orderId` as the current attempt.

  **Both queries run outside the transaction.** Before the query, capture the local version: `reconcile_attempt` stores the attempt owner, Session ID, `status`, `lastEventCreated`, and `attemptCreatedAt`; `reconcile_session` stores the Session ID, current pointer/confirmation flag, and an ownership-and-state fingerprint of every attempt. Only once a result is in hand does the transaction open and lock the merchant order — same lock, same order as the webhook handler — then re-read and compare that snapshot. Any mismatch means the API result may be stale: roll back and retry instead of writing it.

  **An unsettled result must throw so the backoff retries it.** Never mark the task `succeeded` just because this attempt could not determine the answer: a `GET /order` status outside the map, or a `GET /checkout/session` with no `orderId` yet, both fall into that category.

  The current attempt lives in `merchant_orders.current_clink_order_id`, while `current_attempt_confirmed` records whether it came from the Session query. A pointer inferred from `order.created` is not allowed to break a timestamp tie. **Do not touch `attempt_created_at`** — that is the real creation time from Clink, and rewriting it corrupts the source data; "raise it to the group maximum" would not reliably break the tie either.

  Before writing it, look the Session's `orderId` up globally inside the lock. No attempt means the Order has not been persisted locally yet, so throw and retry. An existing attempt owned by another merchant or Session is instead a deterministic conflict: persist its manual-review evidence, mark the event `processed`, and stop automatic retries.
</Note>

<Note>
  **The lease guarantees nothing gets stuck permanently — not that a task runs exactly once.** A crash or an expired lease means the task is reclaimed and runs again, so `fulfill()`, `reconcileRefund()`, `applyRefundPolicy()`, and `reprocessWebhook()` must each be protected by a **business idempotency key**: the order number for fulfillment, `refundId` for refund bookkeeping and refund policy, and `event.id` for event handling. Refund policy must also compare cumulative `refundedAmount` (or a monotonic refund version) so a delayed older task cannot roll entitlement backward. Where the external API accepts an idempotency key, send it too.
</Note>

`clinkGet` is the bodyless GET wrapper around the shared server helper above. It accepts only controlled relative paths, computes a fresh millisecond `X-Timestamp`, forwards `AbortSignal`, requires both HTTP success and a `{ code: 200, data: object }` envelope, and raises a sanitized error without copying the Secret Key or response body.

Where reconciling refunds is not worth the machinery, there is a simpler rule: **sum successful refunds by `refundId` and compare that `Decimal` total against `refundable_paid_amount`**. Clink returns no running refunded total for an order, and there is no `order.refunded` event, so that sum has to be maintained locally anyway.

<Warning>
  **Never `await` this worker from the webhook request thread.**

  Run external work before returning 200 and a fulfillment failure turns into a non-2xx, Clink redelivers, dedup on `event.id` bounces that redelivery straight back to 200 — and **that order never ships**. Acknowledge as soon as the transaction commits, and leave every external action to the worker's retries.
</Warning>

<Note>
  The outbox table needs at least `status`, `attempt`, `next_retry_at`, and `last_error`. Key the worker's idempotency on `eventId` or the order number, because it will be re-run.

  Fulfillment, emails, and third-party calls cannot be rolled back with the transaction, so they must not run inside it. The reverse is equally wrong: calling an external queue with `enqueueXxx()` from inside the transaction leaves an orphaned task behind whenever that transaction rolls back. **The to-do belongs in the same transaction as the business state.**
</Note>

<Note>
  Whether access is revoked should not follow the payment state — refunding half the money might mean cutting service off, prorating it, or changing nothing, depending on what is being sold. Keep that decision in something like `applyRefundPolicy` rather than spreading it through the webhook handler. The Order status advances only when its rank increases, but **every successful `refundId` records its own `apply_refund_policy` task** inside the reconciliation transaction. Run the policy from the Worker after commit; never call it while holding the merchant-order lock.
</Note>

<Warning>
  Order matching, terminal-state protection, and recording 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 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 the local order by the `clinkOrderId` 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 the same event arrives repeatedly. Put a unique index on `event.id`, write that row and the business state in one transaction, and let the database settle concurrent copies. Check-then-write does not stop two deliveries arriving at once.
  </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, and there are two distinct cases.

    **A stale event arrives late**: an order already `paid`, `refunded`, or `partial_refunded` must not be moved back to `pending`. Within one Clink Order, compare `event.created` against the attempt's `last_event_created` and drop anything older.

    **A dependency has not arrived yet**: `refund.succeeded` can precede `order.succeeded`, so the local order does not exist. Keep that event `pending` and queue a reprocess task — **never mark it `processed`**. Once it is marked, Clink stops delivering it and the refund is lost for good.
  </Step>

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

### Scenarios this design has to survive

Run these before going live. Testing the happy path alone will not surface any of them.

**Payment attempt ordering**

| Scenario | Expected result |
| - | - |
| A non-success Attempt has not received `order.created`, with no confirmed current | Store its status, reset a nonterminal old aggregate to `pending`, queue one `reconcile_session` plus `reprocess_webhook`, and keep the event `pending` |
| `order.succeeded` arrives before `order.created` | Success is globally decisive: write the refund basis, move to `paid`, process the event, and queue exactly one fulfillment; the later created event only fills time |
| `order.created` arrives late for an already `succeeded` attempt | Only `attemptCreatedAt` is filled in; the status does not roll back |
| A failed known Order A is followed by unordered Order B `next_action` | B is stored, the old `payment_failed` aggregate becomes `pending`, and Session reconciliation actively discovers the current Order |
| Session reconciliation returns unordered B | Persist B as confirmed current; converge to `action_required` or `payment_failed` from B and mark the original event processed |
| A new unordered Order appears after another current was Session-confirmed | Invalidate `currentAttemptConfirmed`, return to `pending`, and issue a fresh Session query |
| Order A's `order.created` arrives before B's, and both share an `attemptCreatedAt` | The inferred pointer is marked unconfirmed, `reconcile_session` is queued, and the Session decides; arrival order is never used |
| Two status events on one Order share an `event.created` | `reconcile_attempt` is queued and the Order query decides |
| `reconcile_attempt` resolves to `failed`, then a later query resolves to a non-failed state | Failure code/message are written for `failed` and cleared for the later state; stale failure details never remain |
| Order A establishes the refund basis, then Order B reports success with a different Order ID, amount, or currency | The old basis is not overwritten; B's attempt and the conflict evidence commit, the Webhook is acknowledged, and semantic replays produce exactly one manual-reconciliation case/task |
| An Order event matches `merchantReferenceId` but carries another Session ID | No attempt is attached to the order; one durable manual-review case/task preserves both Session IDs and the Webhook is acknowledged |

**Payment Attempt ownership and first-insert arbitration**

| Scenario | Expected result |
| - | - |
| A new Clink Order is first observed for merchant order A / Session A | One attempt is inserted with owner A / Session A and normal aggregation continues |
| The same Order is replayed for the same merchant order and Session | The immutable owner is unchanged and the replay is idempotent |
| Each of `order.created`, `order.next_action`, `order.succeeded`, and `order.failed` targets an existing attempt | Both merchant-order and Session ownership are checked before any field can change |
| `order_X` belongs to A / Session A, then B / Session B sends `order.created` for `order_X` | A's status and creation time stay unchanged; B gets no current pointer; exactly one case/task is created |
| The same cross-owner conflict uses `order.next_action`, `order.failed`, or `order.succeeded` | A's status, failure details, and creation time stay unchanged; B gets no refund basis, fulfillment, or aggregate update |
| The merchant order matches but the incoming Session differs | The event becomes a durable `manual_review`; the attempt is not modified |
| The Session matches but the incoming merchant order differs | The event becomes a durable `manual_review`; the attempt is not modified |
| The same ownership conflict is redelivered with the same or a different `event.id` | Stable key `order_ownership_conflict:{incomingMerchantOrderId}:{clinkOrderId}` leaves one business case and one `manual_reconciliation` Outbox row |
| A and B concurrently first-insert the same `clinkOrderId` | The primary key chooses one owner; the loser rereads it, records `manual_review`, and acknowledges instead of returning a generic 500 |
| Two events for the same owner concurrently first-insert one `clinkOrderId` | No false manual case is created; the result still follows `event.created` ordering and terminal-state rules |
| The winning owner sends later valid events after a concurrent ownership conflict | Processing continues normally without locking or mutating the losing merchant order |
| A conflicting `order.succeeded` targets an attempt owned elsewhere | `refundablePaidClinkOrderId`, amount, and currency are not written or overwritten |
| Any ownership conflict is quarantined | No `fulfill`, `refund_reconcile`, or `reconcile_session` task is created from that conflicting event |
| A manual-review transaction fails while writing the case, Outbox row, or processed marker | The case, task, attempt evidence, and `webhook_events.processed` change all roll back together |
| A leased `manual_reconciliation` task is replayed | Both the desk and alert adapters use `caseKey` as the external idempotency key, so no duplicate ticket or alert is opened |

**Concurrent writes to one order**

| Scenario | Expected result |
| - | - |
| New Order B's `next_action` arrives after old Order A failed, but B's created event is delayed | The aggregate becomes `pending`; after Session returns B it converges to `action_required` |
| `order.succeeded` concurrent with another Order's `order.failed` | Ends as `paid`, with exactly one fulfillment outbox row |
| A late `next_action` / `failed` after `paid` | No rollback |
| Session query returns Order B, then Order C commits before the worker gets the lock | The attempt fingerprint mismatch discards B's stale answer; the worker retries and C is not overwritten |
| Session query returns an Order already owned by another merchant or Session | The ownership evidence is quarantined once, the event is closed, and the deterministic conflict does not consume retries |
| Order query returns a status, then that attempt changes before the worker gets the lock | The version mismatch discards the stale status and retries |

**Out-of-order and multiple Orders**

| Scenario | Expected result |
| - | - |
| One Order: `order.failed` first, then an older `order.next_action` | The attempt stays `failed`; the merchant order stays `payment_failed` and does not roll back |
| A succeeded attempt then receives a late `order.failed` | The attempt stays `succeeded`; the merchant order stays `paid` |
| Same Session: Order A fails, Order B succeeds | The merchant order ends up `paid` |
| Order B is current, then a late event for Order A arrives | Neither Order B nor the aggregate changes |
| The same `event.id` delivered repeatedly | Business state and side effects happen exactly once |

**Session lifecycle**

| Scenario | Expected result |
| - | - |
| Matching `session.complete` | Set only `clinkSessionStatus = completed`, persist its `event.created` version, and mark the Webhook processed |
| Matching `session.expired` | Set only `clinkSessionStatus = expired`, persist its `event.created` version, and mark the Webhook processed |
| Either terminal event after `paid`, `partial_refunded`, or `refunded` | Preserve payment, refund, fulfillment, and all Attempt states |
| Missing `merchantReferenceId` or merchant order | Keep the Webhook pending and queue one `reprocess_webhook` task |
| Missing/conflicting Session ID or a payload status inconsistent with the event type | Keep lifecycle state unchanged and atomically persist one `manual_review` case/task |
| An older Session terminal event arrives after a newer one | Ignore the older lifecycle update by comparing `event.created`; never use arrival time |
| Different terminal states share one `event.created` millisecond | Do not guess; keep the stored lifecycle and persist durable `manual_review` |
| The same terminal event is replayed | The lifecycle version, state, and tasks remain idempotent |

**Out-of-order refunds**

| Scenario | Expected result |
| - | - |
| `refund.succeeded` arrives before `order.succeeded` | The first pass leaves the event `pending` and queues `reprocess_webhook`; once the order exists the worker reprocesses it, the refund is recorded, and the event becomes `processed` |
| Normal `order.succeeded` followed by `refund.succeeded` | The successful Order writes its Order ID, `amountTotal`, and `paymentCurrency`; refund currency validation and reconciliation complete |
| `reconcile_attempt` reaches `success`, then a refund arrives | The Order query backfills the same payment basis, so refund currency validation and reconciliation complete without relying on the success webhook path |
| A refund or any stored successful refund uses a currency different from the saved payment currency | Stop before summing, preserve the stored basis, and escalate to manual reconciliation |
| The same `refund.succeeded` delivered three times | The `webhook_events` unique index rejects the second and third; one refund row, `refundedAmount` not doubled |
| The same `refundId` is replayed with the same immutable business fields | The first refund row is reused exactly; no field is updated and no second amount is counted |
| The same `refundId` reappears with a different amount, currency, Clink Order, or merchant order | The first refund row remains unchanged; one `refund_conflict:{refundId}` case/task preserves both the stored and incoming values |
| An existing `refundId` reappears with an unknown Order ID whose attempt does not exist | The old refund row identifies the merchant case to lock; the mismatch goes straight to `manual_review` instead of being deferred |
| A refund amount is zero, negative, non-finite, or malformed | No refund row or automatic reconciliation task is written; one durable manual-review case records the invalid input |
| Cumulative successful refunds exactly equal the refundable payment basis | The locked total is written and refund reconciliation continues toward `refunded` |
| Cumulative successful refunds exceed the refundable payment basis | `refundedAmount` is not written past the basis and no `refund_reconcile` task is queued; the conflict is durably routed to manual review |
| Two partial refunds succeed while the Order remains `partial_refunded` | Two policy tasks exist, one per `refundId`, each carrying the latest locked cumulative `refundedAmount` at reconciliation time; out-of-order execution cannot roll the applied amount backward |
| A partial refund is followed by the final refund; the first Order query still says `partial_refunded` | Because locked `refundedAmount` has reached `refundablePaidAmount`, the task retries until the Order query returns `refunded`; local status cannot remain partial forever |
| The dependent order never appears | The reprocess task backs off to the cap; one transaction marks it `failed` and creates a pending `page_outbox_failure`. The event is **never recorded as `processed`** |
| Normal order (order first, refund second) | Behaviour is unchanged; handled in a single pass |

**Outbox lease race**

| Step | Expected result |
| - | - |
| Worker A claims the task and gets `tokenA` | `status = processing`, `claim_token = tokenA` |
| The worker is about to start, but a preflight renewal returns 0 rows | Start no external call or side effect; log lease loss only |
| A claim query is asked for a batch | This implementation still returns at most one row, so no later row waits behind the first task's lease |
| A long task remains owned | Heartbeat renewals every `LEASE_MS / 3` keep moving `lease_until`; another worker cannot reclaim it |
| A stalls, the lease expires, Worker B reclaims with `tokenB` | `claim_token` becomes `tokenB`, `lease_until` extends |
| A heartbeat returns 0 rows | Mark the local lease lost, abort supported HTTP calls, start no later step, and skip finish/fail updates |
| Manual desk creation finishes, then ownership is lost | Recheck before paging; do not start the alert step |
| A comes back and reports success or failure | The conditional update affects **0 rows**; log only, no status change |
| B completes normally | The task becomes `succeeded`, **not overwritten by A** |
| A calls `renewLease()` mid-flight | Returns `false`; A must stop immediately |

Because an external call may finish exactly as a heartbeat detects loss, business idempotency is still required: merchant order ID for fulfillment, `refundId` for refund reconciliation/policy, `caseKey` for manual operations, and `event.id` for Webhook replay.

**Outbox exhaustion and paging**

| Scenario | Expected result |
| - | - |
| A task fails below `MAX_ATTEMPT` | Return it to `pending`; create no paging task |
| A task reaches `MAX_ATTEMPT` | Atomically mark it `failed` and insert one `outbox_failure_alert:{taskId}` paging task |
| The exhaustion transaction rolls back or the claim token is stale | Commit neither the `failed` state nor a paging task |
| The worker process exits immediately after the exhaustion transaction | The independent paging task remains durable and `pending` |
| Paging succeeds | Await the adapter, then mark `page_outbox_failure` `succeeded` |
| Paging fails | Return the same paging task to `pending` with capped backoff; never recurse |

### Which events to subscribe to

For one-time payments, at least these:

| Event | What to do |
| - | - |
| `order.created` | **Required.** Its `event.created` is the only basis for ordering payment attempts |
| `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.complete` | Set the independent Session lifecycle to `completed`; do not infer payment success |
| `session.expired` | Set the separate `clinkSessionStatus` to `expired`; never translate Session expiry into payment failure |
| `refund.succeeded` | Refund processed. Record it by `refundId`; settle the Order status separately |

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.

Where a public address already exists — a preview deployment, a Vercel or Netlify URL, an owned 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 each time.

#### Testing verification without paying

A tunnel plus a real payment proves the whole chain, but paying with a card after every tweak to the verification logic is slow. The events below are **merchant-owned local fixtures**, not events obtained from Clink. The referenced merchant order and `clinkSessionId` must already exist in the local database.

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

const WEBHOOK_URL = 'http://127.0.0.1:3000/api/webhooks/clink';
const signingKey = process.env.CLINK_WEBHOOK_SIGNING_KEY;
if (!signingKey) throw new Error('CLINK_WEBHOOK_SIGNING_KEY is required');

async function sendSignedEvent(event, { signatureOverride } = {}) {
  // Sign each exact serialized body with a fresh delivery timestamp.
  const rawBody = JSON.stringify(event);
  const timestamp = String(Date.now());
  const signature = signatureOverride ?? crypto
    .createHmac('sha256', signingKey)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const res = await fetch(WEBHOOK_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Clink-Timestamp': timestamp,
      'X-Clink-Signature': signature,
      'X-Clink-SignType': 'SHA256',
    },
    body: rawBody,
  });
  console.log(event.id, res.status, await res.text());
  return res.status;
}

const merchantReferenceId = 'order_10001'; // must already exist locally
const sessionId = 'sess_test_001';          // must match local clinkSessionId
const orderId = 'order_test_001';

const createdEvent = {
  id: 'event_order_created_test',
  object: 'event',
  type: 'order.created',
  created: 1750000000000,
  data: {
    object: {
      orderId,
      merchantReferenceId,
      sessionId,
      status: 'created',
    },
  },
};

const succeededEvent = {
  id: 'event_order_succeeded_test',
  object: 'event',
  type: 'order.succeeded',
  created: 1750000001000,
  data: {
    object: {
      orderId,
      merchantReferenceId,
      sessionId,
      status: 'success',
      amountTotal: 19.99,
      paymentCurrency: 'USD',
    },
  },
};

const olderFailedEvent = {
  id: 'event_order_failed_old_test',
  object: 'event',
  type: 'order.failed',
  created: 1750000000500, // older than the succeeded status version
  data: {
    object: {
      orderId,
      merchantReferenceId,
      sessionId,
      status: 'failed',
      failureCode: 'fixture_declined',
      failureMessage: 'Local fixture only',
    },
  },
};

// Executable happy path, exact duplicate, then an out-of-order old failure.
await sendSignedEvent(createdEvent);
await sendSignedEvent(succeededEvent);
await sendSignedEvent(succeededEvent); // same Event ID and identical business object
await sendSignedEvent(olderFailedEvent);

// Negative fixtures: run individually when checking these branches.
const mismatchedSessionEvent = {
  ...succeededEvent,
  id: 'event_order_succeeded_wrong_session_test',
  data: {
    object: { ...succeededEvent.data.object, sessionId: 'sess_other' },
  },
};
// await sendSignedEvent(mismatchedSessionEvent); // durable manual_review, no payment update
// await sendSignedEvent(createdEvent, { signatureOverride: '0'.repeat(64) }); // expect 401
```

Expected local results:

| To test | Change |
| - | - |
| Created, then succeeded | `paymentStatus = paid`; refund basis is `order_test_001 / 19.99 / USD`; one fulfillment task |
| Duplicate delivery | The exact same `succeededEvent` is sent twice; the second returns 200 without another fulfillment |
| Out-of-order | `olderFailedEvent` is delivered after success but has an older status timestamp; the order stays paid |
| Bad signature | Run the commented `signatureOverride` call — expect 401 |
| Mismatched Session | Run the commented mismatch fixture — one durable `manual_review` case/task, no payment update |

## 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 the merchant 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 that 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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.