Skip to main content
SandPay pushes a webhook to your server as soon as a transaction reaches its final state. No polling required.
The canonical integration contract (statuses, commission, signature verification, --no-verify-jwt, local vs cloud Inngest) lives in the Integration Guide — the source of truth. Webhook not firing in local dev, reconciling on net_amount, USER_CANCELLED being terminal… the Integration FAQ covers the most common pitfalls.

Events

EventWhen
payment.completedThe transaction has reached its final status (success or failure).
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.
The @sandpay/node SDK exposes webhooks.parseEvent(body) which returns a typed WebhookPayload after verification. See Node SDK.

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

{
  "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.
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.

Signature verification

Every delivery is signed. The headers are:
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

import { SandPay } from "@sandpay/node";

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

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));
}
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.

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.