> ## 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 FAQ & tips

> Answers to the questions integrators actually hit — webhooks, commission, reconciliation, and local-dev gotchas.

The short, battle-tested answers to the questions developers run into while
wiring SandPay into a real app. If you only read one thing: **reconcile on
`net_amount`, match webhooks by `tx_id`, and treat polling as your safety net.**

<Note>
  This page is the quick Q\&A companion. The complete, authoritative contract —
  endpoints, statuses, commission, webhooks, and the recommended architecture —
  lives in the [**Integration Guide**](/en/integration-guide). When in doubt, the
  guide is the source of truth.
</Note>

## FAQ

<AccordionGroup>
  <Accordion title="Why isn't my webhook firing in local dev?">
    SandPay dispatches webhooks through **Inngest**. The behaviour differs by
    where SandPay itself is running:

    * **Local SandPay** (`next dev`): the webhook only fires 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` set in SandPay's env. Without it, events queue but **no
      webhook is delivered**. So when you test against a local SandPay, treat
      [polling](#what-status-values-should-i-handle) as the primary signal — the
      webhook is best-effort in that setup.
    * **Cloud SandPay** (deployed + Inngest cloud): webhooks fire **reliably and
      automatically** — use them as your primary signal.

    Separately, SandPay also needs a **publicly-reachable URL** for *your*
    receiver. In local dev, expose your handler with **ngrok using a reserved
    static domain** so the URL is stable and you set the webhook URL **once**:

    ```bash theme={null}
    ngrok http 4000 --domain=your-name.ngrok-free.app   # use your backend's port
    ```

    Claim the free static domain once at
    [dashboard.ngrok.com/cloud-edge/domains](https://dashboard.ngrok.com/cloud-edge/domains).
    Plain `ngrok http` and `cloudflared` quick-tunnels rotate the URL on every
    restart — avoid them for repeated local testing.

    Either way, keep a polling fallback (see "Webhook vs polling" below).
  </Accordion>

  <Accordion title="The amount debited differs from what I sent — why?">
    SandPay applies an **operator commission** on every payment. Three amounts
    matter:

    * `amount` — what you requested.
    * `customer_total` — what the customer's SIM is **debited** (`= net_amount + commission`).
    * `net_amount` — what the merchant is **credited** (`= amount − merchant_share`).

    By default (`merchant_absorption_pct = 100`) the merchant absorbs the whole
    fee: the customer pays exactly `amount`, and the merchant receives
    `amount − commission`. The rate (`commission_bps`) and absorption split are
    configurable **per environment** in Settings → Countries & operators.

    See [Reconcile merchant proceeds](#how-do-i-reconcile-merchant-proceeds) for
    what to record.
  </Accordion>

  <Accordion title="Where do I put my API key?">
    On your **backend, only**. The `sp_sk_test_…` key must never
    appear in a web bundle, a mobile app, or any client-side code — anyone who
    sees it can charge against your account. Your client calls *your* backend;
    your backend calls SandPay with the bearer key. See
    [Authentication](/en/authentication) for the full contract.
  </Accordion>

  <Accordion title="How do I support multiple operators / countries?">
    SandPay is multi-environment. Each operator+country pair is one environment
    you configure in Settings → Countries & operators. At checkout you don't pick
    the operator manually — it's **auto-detected from the payer's MSISDN** (e.g.
    `+25078…`/`+25079…` → MTN Rwanda). To add a new operator: enable its
    environment in Settings, then add the matching entry to your integration's
    env table (the starter kit calls this `ENABLED_ENVS`) and restart the backend.
  </Accordion>

  <Accordion title="How do I test a specific failure (insufficient funds, cancelled, …)?">
    Two ways:

    * **Force an outcome** — pass an explicit `scenario` on the create request
      (`success`, `pin_invalid`, `low_balance`, `timeout`, `blocked`,
      `cancelled`, `unknown_msisdn`, `limit_exceeded`, `maintenance`,
      `duplicate`). Deterministic, no setup. See [Scenarios](/en/scenarios).
    * **Use test\_clients as a SIM registry** — omit `scenario` and let the
      merchant's test\_clients drive the outcome: an unknown number →
      `UNKNOWN_MSISDN`, a blocked SIM → `ACCOUNT_BLOCKED`, a SIM whose balance is
      below `customer_total` → `INSUFFICIENT_FUNDS`, otherwise `PENDING` (the
      customer confirms on the BigPhone screen). Create test\_clients in `/clients`
      first, or every number returns `UNKNOWN_MSISDN`.
  </Accordion>

  <Accordion title="How do I reconcile merchant proceeds?">
    Use **`net_amount`** from the webhook / Payment, **not** `amount`. `amount` is
    the gross the customer was quoted; `net_amount` is what actually lands with
    the merchant after the operator commission. Crediting your merchant ledger on
    `amount` over-credits by the commission. `net_amount` is `null` while a tx is
    `PENDING` — only post the credit once the tx is terminal and `net_amount` is
    populated.
  </Accordion>

  <Accordion title="What status values should I handle?">
    Map all eleven so nothing falls through to a non-terminal default:

    `SUCCESS`, `PIN_INVALID`, `INSUFFICIENT_FUNDS`, `TIMEOUT`, `ACCOUNT_BLOCKED`,
    `USER_CANCELLED`, `UNKNOWN_MSISDN`, `LIMIT_EXCEEDED`, `SERVICE_UNAVAILABLE`,
    `DUPLICATE_REFERENCE`, `PENDING`.

    <Warning>
      Common pitfall: **`USER_CANCELLED` is terminal.** 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.
    </Warning>
  </Accordion>

  <Accordion title="What happens to a PENDING payment the customer never confirms?">
    An hourly cron closes it as `TIMEOUT` (after \~60 min) and fires the
    `payment.completed` webhook. So an abandoned USSD prompt always resolves to a
    terminal outcome on its own — you don't have to expire stale orders yourself.
    Handle `TIMEOUT` as terminal.
  </Accordion>

  <Accordion title="Webhook vs polling — which should I use?">
    **Both.** The webhook gives you an instant push the moment a tx reaches its
    final state; 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](#why-isn-t-my-webhook-firing-in-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.
  </Accordion>

  <Accordion title="How do I refund a customer, or send a payout?">
    SandPay models both directions. To **reverse** a prior `SUCCESS` collection,
    call `POST /v1/payments/{id}/refund` (`sandpay.payments.refund(id, { amount })`
    — omit `amount` for a full refund). To **send** money to an msisdn that isn't
    tied to a prior payment (cashback, top-up, payout), call
    `POST /v1/disbursements` (`sandpay.disbursements.create({ … })`).

    Both debit the merchant float and credit the recipient SIM. **No commission is
    taken on a payout** — the merchant already paid it on the original collection,
    and the operator doesn't refund it (so a full refund leaves the merchant down
    by the original commission). Refunds fire a `payment.refunded` webhook;
    disbursements fire `disbursement.completed`. List them with
    `GET /v1/payments?type=refund` / `?type=disbursement`. See
    [§5 of the Integration Guide](/en/integration-guide#5-refunds--disbursements).
  </Accordion>

  <Accordion title="Can I refund more than the original amount?">
    No. The refundable ceiling is the original gross `amount` minus the sum of
    prior successful refunds. Over-refunding returns `422 exceeds_refundable`;
    a fully-refunded tx returns `422 fully_refunded`. You can issue several partial
    refunds until the remaining refundable reaches zero. If the merchant float is
    too low, the refund is recorded as `INSUFFICIENT_FUNDS` and no money moves.
  </Accordion>
</AccordionGroup>

## Tips

<Tip>
  **Pay-first ordering.** Create the order as a `draft`, call SandPay, and roll
  the draft back (delete) if init fails. You never want an order lingering for a
  payment that never started.
</Tip>

<Tip>
  **Extract the id tolerantly.** Read the transaction id 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>

<Warning>
  **Match the webhook 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>

<Tip>
  **Strip the trailing slash from your base URL.** `${baseUrl}/api/v1/payments`
  doubles to `//api/v1/payments` if `SANDPAY_BASE_URL` ends in `/`. Normalize with
  `baseUrl.replace(/\/+$/, "")`.
</Tip>

<Warning>
  **`USER_CANCELLED` is terminal.** Map it to a final "cancelled" state, or your
  waiting screen will poll to timeout when a payer refuses.
</Warning>

<Tip>
  **Reconcile on `net_amount`.** Credit the merchant ledger with `net_amount`
  (post-commission), never the gross `amount`.
</Tip>

<Tip>
  **In local dev, expose your receiver or rely on polling.** SandPay needs a
  reachable URL to deliver a webhook; in local dev either expose your handler
  with **ngrok + a reserved static domain** (`ngrok http <port>   --domain=your-name.ngrok-free.app` — stable URL, set the webhook URL once)
  **and** run SandPay's Inngest dev server, or just lean on polling.
</Tip>

## See also

<CardGroup cols={2}>
  <Card title="Integration Guide" icon="book" href="/integration-guide">
    The canonical, end-to-end contract — the source of truth.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    First payment in 10 minutes — Stack Builder or manual.
  </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>
</CardGroup>
