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

# Webhooks

> Receiving payment events via push

SandPay pushes a webhook to your server as soon as a transaction reaches its final state. No polling required.

<Note>
  The canonical integration contract (statuses, commission, signature
  verification, `--no-verify-jwt`, local vs cloud Inngest) lives in the
  [**Integration Guide**](/en/integration-guide) — the source of truth. Webhook
  not firing in local dev, reconciling on `net_amount`, `USER_CANCELLED`
  being terminal… the [Integration FAQ](/en/integration-faq) covers the most
  common pitfalls.
</Note>

## Events

| Event               | When                                                               |
| ------------------- | ------------------------------------------------------------------ |
| `payment.completed` | The transaction has reached its final status (success or failure). |

<Note>
  Only one event is currently emitted: `payment.completed`. The final status (`SUCCESS`, `PIN_INVALID`, `TIMEOUT`, …) is carried by the `status` field in the payload, not by the event name.
</Note>

<Note>
  The `@drwintech/sandpay` SDK exposes `webhooks.parseEvent(body)` which returns a typed `WebhookPayload` after verification. See [Node SDK](/en/sdks/node#webhooksparseeventrawbody).
</Note>

## Configuration

From `/webhooks` in the dashboard:

1. Add your endpoint's HTTPS URL.
2. SandPay generates a **signing secret** (`whsec_...`) — copy it, it will not be shown again.
3. Enable the endpoint. You can pause it at any time.

## Payload format

```json theme={null}
{
  "event": "payment.completed",
  "tx_id": "TX_8K3M9F",
  "org_id": "org_123",
  "env_id": "env_456",
  "country": "CI",
  "operator": "orange",
  "amount": "25000",
  "currency": "FCFA",
  "msisdn": "+22507123456",
  "reference": "ORDER-2026-A1",
  "status": "SUCCESS",
  "latency_ms": 1240,
  "created_at": "2026-05-23T10:14:02.412Z",
  "completed_at": "2026-05-23T10:14:03.652Z",
  "scenario": "success",
  "provider_tx_id": "SIM_A1B2C3D4",
  "description": null,
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFULL",
    "subscriber_msisdn": "22507123456",
    "amount": 25000,
    "txnid": "SIM_A1B2C3D4",
    "pay_token": "ORANGE_SIM_A1B2C3D4",
    "inittxnstatus": "200",
    "inittxnmessage": "Ok",
    "confirmtxnstatus": "200",
    "confirmtxnmessage": "Transaction successfully processed",
    "order_id": "ORDER-2026-A1"
  }
}
```

## The `raw` field

The `raw` field contains the **native operator response**, synthesised to reproduce the real shape (`_simulated: true` is set at the top level so you can detect sandbox payloads).

This design lets you write your integration code (e.g. `payload.raw.financialTransactionId` for MTN, `payload.raw.responseCode` for Moov) directly against the sandbox.

<Note>
  The `raw` field is additive: if you don't read it, your webhook handler continues to work as before. Older records (created before the raw passthrough was released) return `raw: null`.
</Note>

## Signature verification

Every delivery is signed. The headers are:

```http theme={null}
X-SandPay-Signature: sha256=<64-hex>
X-SandPay-Event: payment.completed
```

The digest is computed as HMAC-SHA256 over the **raw request body** (exact bytes received, **before** `JSON.parse`).

### With the Node SDK

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

const sp = new SandPay({ apiKey: process.env.SANDPAY_API_KEY! });

const ok = sp.webhooks.verify(
  request.headers["x-sandpay-signature"],
  rawBody,
  process.env.SANDPAY_WEBHOOK_SECRET!,
);

if (!ok) return new Response("invalid signature", { status: 401 });
```

### Manual verification

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

function verify(rawBody: string, header: string, secret: string): boolean {
  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));
}
```

<Warning>
  Verify the signature **before** parsing JSON. Any middleware that re-serialises the body (`bodyParser.json()` in Express, by default) will invalidate the digest. Configure a raw body buffer on your webhook route.
</Warning>

## Retry policy

SandPay attempts delivery **up to 5 times** with exponential backoff managed by Inngest. Each attempt has a 10-second timeout. If the 5th attempt fails:

* The delivery is marked `failed` in `/webhooks/deliveries`.
* The organisation owner receives an alert email.
* You can manually replay the delivery from the UI.

Any HTTP `2xx` code is considered a success. `3xx` redirects are **not** followed automatically — make sure to return a `200`.
