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

> Recevoir les événements de paiement en push

SandPay pousse un webhook vers votre serveur dès qu'une transaction atteint son état final. Pas de polling à mettre en place.

<Note>
  Le contrat d'intégration canonique (statuts, commission, vérification de
  signature, `--no-verify-jwt`, Inngest local vs cloud) vit dans l'[**Integration
  Guide**](/fr/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](/fr/integration-faq) répond aux pièges les plus courants.
</Note>

## Événements

| Événement           | Quand                                                        |
| ------------------- | ------------------------------------------------------------ |
| `payment.completed` | La transaction a atteint son statut final (succès ou échec). |

<Note>
  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.
</Note>

<Note>
  Le SDK `@drwintech/sandpay` expose `webhooks.parseEvent(body)` qui renvoie un `WebhookPayload` typé après vérification. Voir [SDK Node](/fr/sdks/node#webhooksparseeventrawbody).
</Note>

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

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

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

<Note>
  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`.
</Note>

## Vérification de signature

Chaque livraison est signée. L'en-tête est :

```http theme={null}
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

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

### Vérification manuelle

```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>
  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.
</Warning>

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