Passer au contenu principal
SandPay pousse un webhook vers votre serveur dès qu’une transaction atteint son état final. Pas de polling à mettre en place.
Le contrat d’intégration canonique (statuts, commission, vérification de signature, --no-verify-jwt, Inngest local vs cloud) vit dans l’Integration Guide — la source de vérité. Webhook qui ne se déclenche pas en dev local, réconciliation sur net_amount, USER_CANCELLED terminal… la FAQ & tips d’intégration répond aux pièges les plus courants.

Événements

ÉvénementQuand
payment.completedLa transaction a atteint son statut final (succès ou échec).
Pour l’instant un seul événement est émis : payment.completed. Le statut final (SUCCESS, PIN_INVALID, TIMEOUT, …) est porté par le champ status du payload, pas par le nom de l’événement.
Le SDK @sandpay/node expose webhooks.parseEvent(body) qui renvoie un WebhookPayload typé après vérification. Voir SDK Node.

Configuration

Depuis /webhooks dans le dashboard :
  1. Ajoutez l’URL HTTPS de votre endpoint.
  2. SandPay génère un signing secret (whsec_...) — copiez-le, il ne sera plus affiché.
  3. Activez l’endpoint. Vous pouvez le mettre en pause à tout moment.

Format du payload

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

Le champ raw

Le champ raw contient la réponse native de l’opérateur, synthétisée pour reproduire la shape réelle (_simulated: true est posé au top-level pour que vous puissiez détecter les payloads sandbox). Ce design vous permet d’écrire votre code d’intégration (ex. payload.raw.financialTransactionId pour MTN, payload.raw.responseCode pour Moov) directement contre le sandbox.
Le champ raw est additif : si vous ne le lisez pas, votre handler webhook continue de fonctionner comme avant. Les anciennes lignes (avant la sortie du raw passthrough) renvoient raw: null.

Vérification de signature

Chaque livraison est signée. L’en-tête est :
X-SandPay-Signature: sha256=<64-hex>
X-SandPay-Event: payment.completed
Le digest est calculé en HMAC-SHA256 sur le corps brut de la requête (octets exacts reçus, avant JSON.parse).

Avec le SDK Node

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

Vérification manuelle

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));
}
Vérifiez la signature avant de parser le JSON. Tout middleware qui re-sérialise le corps (bodyParser.json() en Express, par défaut) invalidera le digest. Configurez un raw body buffer sur la route webhook.

Politique de retry

SandPay tente la livraison jusqu’à 5 fois avec un backoff exponentiel géré par Inngest. Chaque tentative a un timeout de 10 secondes. Si la 5e tentative échoue :
  • La livraison est marquée failed dans /webhooks/deliveries.
  • L’owner de l’organisation reçoit un email d’alerte.
  • Vous pouvez rejouer manuellement la livraison depuis l’UI.
Tout code HTTP 2xx est considéré comme une réussite. Les 3xx ne sont pas suivis automatiquement — renvoyez bien un 200.