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

# Integration Guide

> The canonical, end-to-end contract for integrating SandPay correctly — endpoints, statuses, commission, webhooks, and the battle-tested architecture.

This is the **authoritative reference** for integrating SandPay. If you read one
page (or point your AI coding agent at one page), read this one. It is the source
of truth: every other doc, starter kit, and `AGENTS.md` defers to it. The
[Integration FAQ](/en/integration-faq) is the quick Q\&A companion; this guide is the
complete contract.

<Note>
  **One-line summary.** Keep the API key server-side, **match webhooks by
  `tx_id`**, **reconcile merchant proceeds on `netAmount`** (not `amount`), map
  **all 11 statuses** (`USER_CANCELLED` is terminal), and keep **polling as your
  safety net** — it is your *primary* signal against a local SandPay.
</Note>

## 1. Overview

SandPay is a **Mobile Money sandbox** for francophone Africa (MTN,
Orange, Moov, Airtel across Côte d'Ivoire, Bénin, Togo, Rwanda). You send one
payment request to one API with one key; SandPay drives the simulation and notifies
you of the outcome. Operator responses are *synthesized* to match the real operator
shape — with realistic latencies, error codes, and the native `raw` payload structure.

### Trust boundary

The single most important rule: **the API key lives only on your server.** A
`sp_sk_test_…` key can charge against your account — it must
never appear in a web bundle, a mobile app, or any client-side code. Your client
calls *your* backend; your backend calls SandPay with the bearer key.

```
   trust boundary
        │
 Client │  Your backend            SandPay              Operator
 ───────│  ───────────             ───────              ────────
   1. checkout ─▶  2. POST /v1/payments ─▶  3. drive operator
        │            (Bearer sp_sk_…)
        │                                   4. async settle
   7. UI update ◀─ 6. update your row ◀──── 5. POST webhook (signed)
        │              ▲
        │              └── + GET /v1/payments/{id} polling (every ~4s)
        │
        └── the API key NEVER crosses this line ───────────────────┘
```

### Request → webhook lifecycle

1. Client starts checkout against **your** backend.
2. Backend `POST /v1/payments` (Bearer key). SandPay returns a `Payment` with an
   `id` and an initial `status` (often `PENDING` — acceptance, not settlement).
3. The customer confirms on their device; SandPay settles the transaction.
4. SandPay `POST`s a signed `payment.completed` webhook to your configured URL.
5. Your backend verifies the signature, matches the event by `tx_id`, and updates
   its row. Polling `GET /v1/payments/{id}` is the resilient fallback.

## 2. The contract

### Endpoints

<Note>
  **Base URL & path.** At the public hostname `https://api.sandpay.dev`, the
  endpoints live under **`/v1/…`** (e.g. `https://api.sandpay.dev/v1/payments`).
  When you point at a **deployed or local SandPay instance** (the Next.js app),
  the same routes are served under **`/api/v1/…`** (e.g. `${SANDPAY_BASE_URL}
      /api/v1/payments`). Strip any trailing slash from your base URL either way —
  see the [checklist](#9-integration-checklist).
</Note>

| Method | Endpoint                   | Description                                                                                    | Auth   |
| ------ | -------------------------- | ---------------------------------------------------------------------------------------------- | ------ |
| `POST` | `/v1/payments`             | Create a (simulated) payment.                                                                  | Bearer |
| `GET`  | `/v1/payments/{id}`        | Retrieve one payment by its `id`.                                                              | Bearer |
| `GET`  | `/v1/payments`             | List payments (cursor pagination: `limit`, `country`, `operator`, `status`, `type`, `cursor`). | Bearer |
| `POST` | `/v1/payments/{id}/refund` | Refund all/part of a collection (payout → customer). See [§5](#5-refunds--disbursements).      | Bearer |
| `POST` | `/v1/disbursements`        | Free-form payout (merchant → an msisdn). See [§5](#5-refunds--disbursements).                  | Bearer |
| `GET`  | `/v1/meta`                 | API version + capabilities (feature-detect). See [Changelog](/en/integration-changelog).       | None   |
| `GET`  | `/v1/health`               | Health probe.                                                                                  | None   |

<Note>
  **Staying current.** Every `/v1/*` response carries an `X-SandPay-Api-Version`
  header, and `GET /v1/meta` returns the version + supported `capabilities` +
  `min_sdk_version`. Feature-detect against `capabilities` rather than assuming
  a version, and watch the [Integration Changelog](/en/integration-changelog) for
  what changed and what (if anything) you must do.
</Note>

### Authentication

Send the key as a Bearer token on every authenticated request. Server-side only.

```http theme={null}
Authorization: Bearer sp_sk_test_a1b2c3d4e5f6...
```

`sp_sk_test_…` keys produce simulated transactions (no money moves). See [Authentication](/en/authentication) for rotation and
revocation.

### Request body (`POST /v1/payments`)

```json theme={null}
{
  "amount": 25000,
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "application": "zana",
  "description": "Premium upgrade",
  "scenario": "success"
}
```

| Field         | Type    | Required    | Notes                                                                                                                                                                                            |
| ------------- | ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `amount`      | integer | yes         | Gross amount in the currency's smallest practical unit (integral for Mobile Money).                                                                                                              |
| `currency`    | string  | yes         | E.g. `RWF`, `XOF`.                                                                                                                                                                               |
| `operator`    | string  | no\*        | `mtn` / `orange` / `moov` / `airtel`. Auto-detected from `msisdn` if omitted.                                                                                                                    |
| `country`     | string  | no\*        | ISO-2, e.g. `RW`, `CI`. Auto-detected from `msisdn` if omitted.                                                                                                                                  |
| `msisdn`      | string  | yes         | Payer number in **E.164** (`+250788123456`).                                                                                                                                                     |
| `reference`   | string  | yes         | **Your** external id (your idempotency key). The webhook does **not** echo it back.                                                                                                              |
| `application` | string  | **yes**     | Your store's slug — found in **Settings → Stores**. Every transaction is tied to a store.                                                                                                        |
| `order_ref`   | string  | recommended | **Your order / commande id** — the key that links a payment, its refunds, and any recurring charges together. Falls back to `reference` when omitted. Shows up on the dashboard **Orders** page. |
| `order_url`   | string  | no          | Public URL to the order in your app — rendered as a "view order" link in the dashboard.                                                                                                          |
| `description` | string  | no          | Informational; stored and returned, does not affect the outcome.                                                                                                                                 |
| `scenario`    | string  | no          | Force a deterministic outcome (see [Multi-environment](#7-multi-environment)).                                                                                                                   |

\* `country`/`operator` are optional **only** when they're auto-detectable from
the MSISDN prefix and the matching environment is enabled in Settings.

<Tip>
  **Always send `order_ref`.** It's how SandPay groups related transactions —
  the original payment, every refund against it, and recurring charges for the
  same order all roll up under one entry on the dashboard **Orders** page (and
  `GET /v1/payments?order_ref=…` / the `order` filter). A refund automatically
  inherits its parent collection's `order_ref`. Omit it and SandPay falls back
  to your `reference`, but a stable order id (e.g. your cart/subscription id) is
  the right grouping key.
</Tip>

### Response — the `Payment` resource

The API returns **camelCase** JSON. (The serializer at
`frontend/lib/payments/serialize.ts` is the single source of truth for this
shape.)

```json theme={null}
{
  "id": "TX_8K3M9F",
  "amount": 25000,
  "commission": 250,
  "netAmount": 24750,
  "customerTotal": 25000,
  "merchantAbsorptionPct": 100,
  "merchantShare": 250,
  "customerShare": 0,
  "commissionMode": "merchant",
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "description": "Premium upgrade",
  "scenario": "success",
  "status": "SUCCESS",
  "latencyMs": 1240,
  "createdAt": "2026-05-24T10:30:45.000Z",
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFUL",
    "financialTransactionId": "SIM_A1B2C3D4"
  }
}
```

| Field                   | Type           | Meaning                                                                                                                |
| ----------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `id`                    | string         | The transaction id (`TX_…`). Store this — the webhook matches on it.                                                   |
| `amount`                | integer        | The gross you requested.                                                                                               |
| `commission`            | integer        | Operator commission = `round(amount × commission_bps / 10000)`.                                                        |
| `netAmount`             | integer        | What the **merchant is credited**. **Reconcile here.** `null`-ish (= gross) for legacy rows; not final until terminal. |
| `customerTotal`         | integer        | What the **customer's SIM is debited** (`= netAmount + commission`).                                                   |
| `merchantAbsorptionPct` | int \| null    | % of the commission the merchant absorbed (0–100). `null` for legacy rows.                                             |
| `merchantShare`         | integer        | Fee borne by the merchant (`= amount − netAmount`).                                                                    |
| `customerShare`         | integer        | Fee borne by the customer (`= commission − merchantShare`).                                                            |
| `commissionMode`        | string \| null | Who absorbed the fee for this tx: `"customer"` / `"merchant"`. `null` for legacy rows.                                 |
| `currency`              | string         | As sent.                                                                                                               |
| `operator`              | string         | Resolved operator.                                                                                                     |
| `country`               | string         | Resolved country.                                                                                                      |
| `msisdn`                | string         | Payer MSISDN.                                                                                                          |
| `reference`             | string         | Your external id.                                                                                                      |
| `description`           | string \| null | As sent.                                                                                                               |
| `scenario`              | string \| null | The forced scenario, or `null`.                                                                                        |
| `status`                | string         | One of the [11 statuses](#3-status-vocabulary).                                                                        |
| `latencyMs`             | integer        | Simulated operator latency.                                                                                            |
| `createdAt`             | string         | ISO 8601.                                                                                                              |
| `raw`                   | object \| null | Operator-native payload. `_simulated: true` at the top level in sandbox. `null` for legacy rows — handle it.           |

<Tip>
  **Extract the id tolerantly.** Read it from `id`, but tolerate `tx_id` /
  `txId` / `transaction_id` and one level of nesting (`data.id`, `payment.id`)
  so a naming difference never reads as a failure.
</Tip>

## 3. Status vocabulary

There are exactly **11** canonical statuses. Map all of them so nothing falls
through to a non-terminal default.

| Status                | Terminal? | Meaning                                                                             |
| --------------------- | --------- | ----------------------------------------------------------------------------------- |
| `SUCCESS`             | ✅ yes     | Payment completed.                                                                  |
| `PIN_INVALID`         | ✅ yes     | Wrong PIN.                                                                          |
| `INSUFFICIENT_FUNDS`  | ✅ yes     | Balance below `customerTotal`.                                                      |
| `TIMEOUT`             | ✅ yes     | Operator timed out — or an abandoned `PENDING` prompt the hourly cron auto-expired. |
| `ACCOUNT_BLOCKED`     | ✅ yes     | SIM/account blocked.                                                                |
| `USER_CANCELLED`      | ✅ yes     | Payer refused the prompt. **Terminal.**                                             |
| `UNKNOWN_MSISDN`      | ✅ yes     | Number not in the SIM registry.                                                     |
| `LIMIT_EXCEEDED`      | ✅ yes     | Operator transaction limit exceeded.                                                |
| `SERVICE_UNAVAILABLE` | ✅ yes     | Operator in maintenance.                                                            |
| `DUPLICATE_REFERENCE` | ✅ yes     | Duplicate `reference`.                                                              |
| `PENDING`             | ❌ no      | In flight — keep polling / await the webhook.                                       |

<Warning>
  **`USER_CANCELLED` is terminal** — and it is the single most common integrator
  pitfall. A payer refusing the prompt is a *final* outcome, not a retry. If you
  leave it mapped to "pending", your waiting screen polls until it times out.
  Map it to a final "cancelled" state.
</Warning>

`PENDING` is the only non-terminal status. Every other status is final and fires
the `payment.completed` webhook.

<Note>
  **Abandoned prompts auto-expire.** A `PENDING` payment the customer never
  confirms (they didn't answer the USSD push) is auto-closed to `TIMEOUT` by an
  hourly cron, after \~60 minutes. You receive a `payment.completed` webhook with
  status `TIMEOUT` — so a stuck prompt always resolves to a terminal outcome on
  its own. Handle `TIMEOUT` as a terminal failure.
</Note>

## 4. Commission & settlement

SandPay applies an **operator commission** on every payment. Three amounts matter:

* `amount` — what you requested (the gross).
* `customerTotal` — what the customer's SIM is **debited** (`= netAmount + commission`).
* `netAmount` — what the merchant is **credited**.

### Formula & invariant

```
commission     = round(amount × commission_bps / 10000)
merchantShare  = round(commission × merchantAbsorptionPct / 100)   (the fee the merchant eats)
customerShare  = commission − merchantShare                         (the fee the customer eats)
netAmount      = amount − merchantShare
customerTotal  = netAmount + commission
```

**Invariant (always holds):** `customerTotal === netAmount + commission`.

### The default: merchant absorbs everything

`merchant_absorption_pct` defaults to **100** — the merchant absorbs the whole
fee. The customer then pays exactly `amount`, and the merchant receives
`amount − commission`. At the other extreme (`0`), the customer pays
`amount + commission` and the merchant receives `amount`. Both knobs (the rate
`commission_bps` **and** the absorption split) are configurable **per
environment** in **Settings → Countries & operators**.

<Warning>
  **Reconcile on `netAmount`, never `amount`.** `amount` is the gross the
  customer was quoted; `netAmount` is what actually lands with the merchant
  after the commission. Crediting your merchant ledger on `amount` over-credits
  by the commission. `netAmount` isn't final while a tx is `PENDING` — only post
  the credit once the tx is terminal.
</Warning>

## 5. Refunds & disbursements

The endpoints above all model **collections** (pay-in: customer → merchant).
SandPay also supports the **outbound** direction (payout: merchant → customer):

* **Refund** — reverse all or part of a prior `SUCCESS` collection. Linked to
  the original via `parentTxId`.
* **Disbursement** — a free-form payout to an msisdn, not linked to any prior
  collection (cashback, top-up, payout).

Both **debit the merchant float** (`merchant_balance`) and **credit the
recipient SIM**. **No commission is taken on a payout** — the merchant already
paid the operator's commission when it collected the original payment; the
operator does not refund it. So a full refund leaves the merchant down by the
original commission (this matches real operator behaviour).

Payouts are persisted as transactions with a `type` of `refund` or
`disbursement` (collections are `type: "collection"`). Filter them with
`GET /v1/payments?type=refund` / `?type=disbursement`.

### Refund a payment

```
POST /v1/payments/{id}/refund
{ "amount": 500 }          // omit `amount` for a full refund of the remaining refundable
```

```ts theme={null}
// Full refund
await sandpay.payments.refund("TX_8K3M9F");
// Partial refund (minor units)
await sandpay.payments.refund("TX_8K3M9F", {
  amount: 500,
  reference: "RFND-ORDER-42",
});
```

The refundable ceiling is the original gross `amount` minus the sum of prior
successful refunds. Over-refunding returns `422 exceeds_refundable`; refunding a
non-`SUCCESS` or non-collection tx returns `422`. If the merchant float is too
low, the refund is recorded with status `INSUFFICIENT_FUNDS` (no money moves).

### Create a disbursement

```
POST /v1/disbursements
{ "amount": 5000, "currency": "XOF", "operator": "mtn", "country": "CI", "msisdn": "+22507000000" }
```

```ts theme={null}
await sandpay.disbursements.create({
  amount: 5000,
  currency: "XOF",
  operator: "mtn",
  country: "CI",
  msisdn: "+22507000000",
});
```

The recipient is resolved against the env's test-client registry + its
unknown-msisdn policy, exactly like collections:

| Recipient                        | Outcome                     |
| -------------------------------- | --------------------------- |
| Active test\_client              | `SUCCESS` — SIM credited    |
| Blocked test\_client             | `ACCOUNT_BLOCKED`           |
| Unknown msisdn (`reject` policy) | `UNKNOWN_MSISDN`            |
| Unknown msisdn (`passthrough`)   | `SUCCESS` — no SIM credited |
| Merchant float \< amount         | `INSUFFICIENT_FUNDS`        |

### Payout webhooks

Payouts fire the same signed webhook as collections, with a direction-specific
event name (the terminal `status` is always in the body — check it, not just the
event):

| Transaction `type` | Webhook `event`          | `X-SandPay-Event` header |
| ------------------ | ------------------------ | ------------------------ |
| `collection`       | `payment.completed`      | `payment.completed`      |
| `refund`           | `payment.refunded`       | `payment.refunded`       |
| `disbursement`     | `disbursement.completed` | `disbursement.completed` |

You can also refund a `SUCCESS` collection straight from the dashboard
(transaction detail page → **Refund this transaction**), which is handy for
manual testing.

### Traceability — carry the source `order_ref` into every payout

For investigation later, every transaction tied to one order should share the
**same `order_ref`** (the source order's id) — so a payment, its refunds, and any
related disbursement all trace back to one order (dashboard **Orders** page,
`GET /v1/payments?order=<order_ref>`, the `order_ref` on the webhook):

* **Refund** — `order_ref` (and `order_url`) are **inherited automatically** from
  the parent collection. Don't re-send them; just refund against the correct
  original tx id. (Persist that tx id on your order at payment time.)
* **Disbursement** — has no parent, so it inherits **nothing**: pass `order_ref`
  \= the **same** value you used on the original collection. Omit it and the
  payout falls back to its own `reference` and won't group with the order.
* **Recurring / subscription** — reuse one `order_ref` (e.g. the subscription id)
  across every charge.

Pass **`is_url_protected: true`** alongside `order_url` when the order page on
your app needs a specific permission to open (admin-only, not a public link).
SandPay then shows a **"⚠ protected URL"** hint next to the link on the Orders +
transaction-detail pages, so whoever investigates knows they'll need that right
first. Defaults to `false`. (A refund inherits the flag from its parent, like
`order_ref`/`order_url`.)

<Warning>
  Never use a fresh `refund-…` / `payout-…` string as the grouping key — that's
  fine as the idempotency `reference`, but `order_ref` must stay the **source
  order's** id, or the payout floats unlinked from its order.
</Warning>

### Reference format — `ORIGIN-OP-TS-CODE`

`reference` is **your idempotency key** (unique per org). SandPay recommends a
single canonical shape for it so every transaction is consistent and instantly
readable + sortable on the dashboard. Capability: `reference_format`.

```
ORIGIN-OP-TS-CODE     e.g.  ZANA-PAY-20260531221015-8E2884A47B1C
```

| Segment  | Rule                                                                                                           |
| -------- | -------------------------------------------------------------------------------------------------------------- |
| `ORIGIN` | Your app's slug — uppercase, 2–12 chars (e.g. `ZANA`). SandPay itself uses `SP`.                               |
| `OP`     | 3-letter operation: `PAY` (collection), `REF` (refund), `DIS` (disbursement), `ABO` (subscription).            |
| `TS`     | Compact UTC datetime `YYYYMMDDHHMMSS` — when the reference was minted (sortable).                              |
| `CODE`   | 12-char uppercase hex. **Random** for a fresh attempt, or **derived** from a stable id for idempotent retries. |

<Warning>
  **Idempotency: the WHOLE string must be reproducible.** For a NEW attempt each
  call (e.g. a fresh payment attempt for an order), use a random CODE + current
  TS. For an operation that must dedupe on retry (e.g. a specific refund), make
  it fully reproducible: a CODE **derived from a stable id** (`sha256(id)` → first
  12 hex) **and** a stable `TS` (e.g. the source entity's timestamp) — otherwise a
  retried refund whose TS/CODE drift would refund twice.
</Warning>

The SDK (`@drwintech/sandpay` ≥ 0.3.0) ships helpers so you don't hand-roll this:

```ts theme={null}
import { buildReference } from "@drwintech/sandpay";

// Fresh payment attempt → random CODE + current TS
const reference = buildReference({ origin: "ZANA", operation: "PAY" });
// Idempotent refund → derived CODE + stable TS (the original payment's date)
const refundRef = buildReference({
  origin: "ZANA",
  operation: "REF",
  seed: refundId,
  at: originalPaymentCreatedAt, // Date | epoch-ms — keeps the TS stable on retry
});
// Subscription charge
const subRef = buildReference({ origin: "ZANA", operation: "ABO", seed: `${subId}:${period}` });
```

Hand-rolling (no SDK)? `TS` is the UTC `YYYYMMDDHHMMSS`; the CODE is
`crypto.randomBytes(6).toString("hex").toUpperCase()` (random) or
`crypto.createHash("sha256").update(seed).digest("hex").slice(0,12).toUpperCase()`
(derived). When you omit `reference` on a payout, SandPay auto-generates one in
this format (`SP-REF-…` / `SP-DIS-…`, inheriting the parent's `ORIGIN` for a refund).

<Note>
  This is the format of the **`reference`** (idempotency key) — it's independent
  of **`order_ref`** (the grouping key). Keep sending your stable `order_ref`
  (the source order's id) regardless of how `reference` is shaped. Parsing is
  lenient: pre-`TS` references (`ORIGIN-OP-CODE`) are still recognized.
</Note>

## 6. Webhooks

SandPay `POST`s a `payment.completed` event to your configured URL the moment a
transaction reaches a terminal state. This is a **server-to-server** call carrying
no JWT — its authenticity comes from the HMAC signature.

### Signature verification

Header: `X-SandPay-Signature: sha256=<64-hex>` where the digest is
`HMAC-SHA256(secret, rawBody)` over the **exact raw bytes** received (before any
`JSON.parse`).

* **Verify constant-time.** Use `crypto.timingSafeEqual` (or WebCrypto + a
  constant-time compare) — never a `===` on the hex string.
* **Fail closed.** If the secret is missing or the signature doesn't match, reject
  with `401`. Never process an unverified body.
* **Verify before parsing.** Any middleware that re-serializes the body (e.g.
  Express `bodyParser.json()` by default) invalidates the digest. Capture a raw
  body buffer on the webhook route.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  if (!header || !secret) return false; // fail closed
  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  if (expected.length !== header.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
```

On Deno / Supabase Edge, use WebCrypto (`crypto.subtle.importKey` + `sign`) and a
constant-time hex compare.

<Warning>
  **Match the event by `tx_id`, not your `reference`.** The webhook does **not**
  echo the `reference` you sent. Relate the event to your row by `tx_id` — the
  `id` you stored at creation.
</Warning>

### Deploy receiver with `--no-verify-jwt`

On Supabase, deploy the webhook function with `--no-verify-jwt`
(`supabase functions deploy sandpay-webhook --no-verify-jwt`). SandPay carries no
JWT; gating the route on JWT verification would 401 every delivery. Authenticity
comes from the HMAC signature, which you verify yourself.

### Webhook body

The body is **snake\_case** and carries the same commission fields as the create
response (source of truth: `frontend/lib/webhooks/payload.ts`):

```json theme={null}
{
  "event": "payment.completed",
  "tx_id": "TX_8K3M9F",
  "org_id": "org_123",
  "env_id": "env_456",
  "country": "RW",
  "operator": "mtn",
  "amount": "25000",
  "commission": "250.00",
  "net_amount": "24750.00",
  "customer_total": "25000.00",
  "merchant_absorption_pct": 100,
  "merchant_share": "250.00",
  "customer_share": "0.00",
  "commission_mode": "merchant",
  "currency": "RWF",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "status": "SUCCESS",
  "latency_ms": 1240,
  "created_at": "2026-05-24T10:30:45.000Z",
  "completed_at": "2026-05-24T10:30:46.652Z",
  "scenario": "success",
  "provider_tx_id": "SIM_A1B2C3D4",
  "description": "Premium upgrade",
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFUL",
    "financialTransactionId": "SIM_A1B2C3D4"
  }
}
```

Return any `2xx` to acknowledge. SandPay retries up to 5 times with exponential
backoff; `3xx` is **not** followed. See [Webhooks](/en/webhooks) for the retry
policy and the `@drwintech/sandpay` `webhooks.verify` / `webhooks.parseEvent` helpers.

### Inngest: local vs cloud (critical for local dev)

SandPay dispatches webhooks **through Inngest**. Whether a webhook fires depends
on where SandPay itself runs:

* **Local SandPay** (`next dev`): the webhook fires **only if** SandPay's Inngest
  dev server is running **and synced**:

  ```bash theme={null}
  npx inngest-cli@latest dev -u http://localhost:3800/api/inngest
  ```

  with `INNGEST_DEV=1` in SandPay's env. Without it, events **queue but no webhook
  is delivered**. So when you test against a local SandPay, treat **polling as the
  primary signal** — the webhook is best-effort there.

* **Cloud SandPay** (deployed + Inngest cloud): webhooks fire **reliably and
  automatically** — use them as your primary signal, with polling as the safety net.

### Expose your local receiver with a stable tunnel (ngrok static domain)

Webhooks (and a cloud SandPay reaching your local SandPay) need a
**publicly-reachable HTTPS URL**. In local dev, expose your handler with **ngrok
using a reserved static domain** — the free tier includes **one** reserved
domain, so the URL is **stable across restarts** and you set
`SANDPAY_BASE_URL` / the webhook URL **once**.

```bash theme={null}
# 1. Install ngrok
#    macOS:    brew install ngrok
#    Linux:    sudo snap install ngrok   (or download from ngrok.com/download)
#    Windows:  scoop install ngrok       (or download the .exe)
#    npm:      npm install -g ngrok

# 2. Authenticate (one time) — copy your token from
#    https://dashboard.ngrok.com/get-started/your-authtoken
ngrok config add-authtoken <YOUR_AUTHTOKEN>

# 3. Claim your free static domain (one time) at
#    https://dashboard.ngrok.com/cloud-edge/domains  → "New Domain"
#    You get one free reserved domain like:  your-name.ngrok-free.app

# 4. Start the tunnel with your STATIC domain (URL never changes)
ngrok http 3800 --domain=your-name.ngrok-free.app
#    (use the port of whatever you're exposing: 3800 SandPay, 4000 Express/Hono,
#     8000 FastAPI, 3000 Next.js, 54321 Supabase functions)

# 5. Set the base URL / webhook URL ONCE — it stays valid across restarts:
#    SANDPAY_BASE_URL=https://your-name.ngrok-free.app
```

<Tip>
  The `--domain=<static>` flag is the whole point: the URL is the **same every
  time**, so you set `SANDPAY_BASE_URL` / the webhook URL **once and never touch
  it again**. Plain `ngrok http` (no `--domain`) and `cloudflared` quick-tunnels
  rotate the subdomain on every restart — forcing you to re-set the secret and
  re-paste the webhook URL each time. Avoid them for repeated local testing.
</Tip>

## 7. Multi-environment

Each `operator + country` pair is one **environment** you configure in
**Settings → Countries & operators**.

* **Auto-detection.** At checkout you don't pick the operator manually — it's
  auto-detected from the payer's MSISDN prefix (e.g. `+25078…` / `+25079…` → MTN
  Rwanda). You may still pass `country` / `operator` explicitly.

* **Enable the env first.** If the resolved environment isn't enabled in Settings,
  the API errors (`env_not_found` / a 5xx for an unconfigured operator). Enable
  the operator/country/currency env before sending.

* **`test_clients` = the SIM registry.** With **no `scenario`**, the outcome is
  driven by the merchant's test clients:

  * unknown number → `UNKNOWN_MSISDN`
  * blocked SIM → `ACCOUNT_BLOCKED`
  * balance below `customerTotal` → `INSUFFICIENT_FUNDS`
  * otherwise → `PENDING` (the customer confirms on the BigPhone screen)

  Create test clients in `/clients` first, or every number returns
  `UNKNOWN_MSISDN`.

* **Explicit `scenario` forces a deterministic outcome** (ideal for CI):
  `success`, `pin_invalid`, `low_balance`, `timeout`, `blocked`, `cancelled`,
  `unknown_msisdn`, `limit_exceeded`, `maintenance`, `duplicate`. An explicit
  scenario always wins over the SIM registry. See [Scenarios](/en/scenarios).

## 8. Recommended architecture

### Pay-first ordering

Create the order as a **`draft`**, call SandPay, and:

* **init fails** → **roll the draft back (delete it)** and surface the error.
* **init succeeds** → move the order to `initiated`/`pending`.
* **terminal confirmation** (webhook or polling) → **promote** to its final state.

You never want an order lingering for a payment that never started. Surface
synchronous init failures to the UI (error message + back to the payment screen),
not just a silent rollback.

### Webhook + polling

Use **both**. The webhook is an instant push (it needs a public URL + the shared
secret + `--no-verify-jwt`); polling `GET /v1/payments/{id}` every \~4s is the
resilient fallback for when the webhook is delayed, dropped, or — in local dev —
[not wired at all](#inngest-local-vs-cloud-critical-for-local-dev). Against a cloud
SandPay, the webhook is your primary signal and polling is the safety net; against
a local SandPay, **polling is effectively primary**.

```
Client ──(operator, phone, amount)──▶ YOUR BACKEND  (initiate-payment)
                                        │ 1. create order as `draft`
                                        │ 2. POST /v1/payments (Bearer key)
                                        │ 3a. fail → rollback (delete draft) → error
                                        │ 3b. ok   → tx "initiated" → return id to client
Client (waiting screen) ◀───────────────┘
   ├─ webhook resolution (instant)            ── SandPay ──(signed webhook)──▶ YOUR BACKEND
   └─ polling GET /v1/payments/{id} (~4s)        (payment-webhook): verify HMAC,
                                                  match by tx_id, update row,
                                                  reconcile merchant ledger on netAmount
   terminal = SUCCESS / a failure / USER_CANCELLED → waiting screen resolves
   everything else (PENDING) → keep polling
```

### Reconciliation

When a transaction settles, credit your merchant ledger with **`netAmount`**
(post-commission), never the gross `amount`. Only post the credit once the tx is
terminal and `netAmount` is populated.

## 9. Integration checklist

* [ ] Base URL has **no trailing slash** (`baseUrl.replace(/\/+$/, "")`) so
  `${baseUrl}/api/v1/payments` doesn't double to `//api/v1/payments`.
* [ ] `SANDPAY_API_KEY` + `SANDPAY_WEBHOOK_SECRET` are **server-side secrets**,
  never in client code, never committed.
* [ ] The operator/country/currency **environment is enabled** in Settings →
  Countries & operators (an unconfigured env errors).
* [ ] The transaction id is read **tolerantly** (`id`, then `tx_id` / `txId` /
  `transaction_id`, then nested `data.id` / `payment.id`).
* [ ] **All 11 statuses are mapped**, and **`USER_CANCELLED` is terminal**.
* [ ] The webhook receiver is deployed with **`--no-verify-jwt`**, verifies the
  **HMAC** signature constant-time + fail-closed, and **matches by `tx_id`**
  (not `reference`).
* [ ] Webhook URL is pasted into the SandPay dashboard; the secret is identical on
  both sides.
* [ ] Merchant proceeds are **reconciled on `netAmount`**, not `amount`, and only
  once terminal.
* [ ] **Pay-first**: orders are created as `draft`, rolled back on init failure,
  promoted on confirmation — no orphan orders.
* [ ] A **polling fallback** (`GET /v1/payments/{id}`, \~4s) backs up the webhook.
* [ ] In **local dev**, SandPay's **Inngest dev server** is running
  (`inngest-cli dev -u http://localhost:3800/api/inngest` + `INNGEST_DEV=1`)
  **or** polling is relied upon as the primary signal.
* [ ] Tested end to end: success, **payer refusal (`USER_CANCELLED`)**,
  insufficient funds, and timeout.

## 10. See also

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/quickstart">
    First payment in 10 minutes — Stack Builder or manual.
  </Card>

  <Card title="Integration FAQ & tips" icon="circle-question" href="/integration-faq">
    The quick Q\&A companion to this guide.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/webhooks">
    Payload shape, signature verification, retries.
  </Card>

  <Card title="Scenarios" icon="list" href="/scenarios">
    Force any operator outcome via the `scenario` field.
  </Card>

  <Card title="Node SDK" icon="code" href="/sdks/node">
    @drwintech/sandpay — typed client + webhook helpers.
  </Card>

  <Card title="Integration Changelog" icon="timeline" href="/integration-changelog">
    What changed, per API version — and what to do about it.
  </Card>

  <Card title="Upgrade & (re)install" icon="rotate" href="/integration-upgrade">
    Step-by-step runbook to install, reinstall, or upgrade from your app.
  </Card>
</CardGroup>
