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

# Guide d'intégration

> Le contrat de référence, de bout en bout, pour intégrer SandPay correctement — endpoints, statuts, commission, webhooks et l'architecture éprouvée.

Voici la **référence faisant autorité** pour intégrer SandPay. Si vous ne lisez
qu'une seule page (ou que vous pointez votre agent de code IA sur une seule page),
lisez celle-ci. C'est la source de vérité : tous les autres docs, kits de démarrage
et fichiers `AGENTS.md` en découlent. La [FAQ d'intégration](/fr/integration-faq)
est le guide de questions-réponses rapide ; ce guide-ci est le contrat complet.

<Note>
  **Résumé en une ligne.** Gardez la clé API côté serveur, **associez les webhooks
  par `tx_id`**, **réconciliez les versements marchands sur `netAmount`** (pas sur
  `amount`), mappez **les 11 statuts** (`USER_CANCELLED` est terminal), et
  conservez le **polling comme filet de sécurité** — c'est votre signal *primaire*
  contre un SandPay local.
</Note>

## 1. Vue d'ensemble

SandPay est un **sandbox Mobile Money** pour l'Afrique francophone (MTN,
Orange, Moov, Airtel sur la Côte d'Ivoire, le Bénin, le Togo et le Rwanda). Vous
envoyez une requête de paiement à une seule API avec une seule clé ; SandPay
orchestre la simulation et vous notifie du résultat. Les réponses des opérateurs
sont *synthétisées* pour correspondre à la forme réelle de l'opérateur — avec des
latences réalistes, des codes d'erreur et la structure native du payload `raw`.

### Frontière de confiance

La règle la plus importante : **la clé API ne vit que sur votre serveur.** Une
clé `sp_sk_test_…` peut débiter votre compte — elle ne doit jamais apparaître
dans un bundle web, une application mobile ou tout code côté client. Votre client
appelle *votre* backend ; votre backend appelle SandPay avec la clé Bearer.

```
   frontière de confiance
        │
 Client │  Votre backend            SandPay              Opérateur
 ───────│  ───────────             ───────              ────────
   1. checkout ─▶  2. POST /v1/payments ─▶  3. drive operator
        │            (Bearer sp_sk_…)
        │                                   4. async settle
   7. UI update ◀─ 6. update your row ◀──── 5. POST webhook (signed)
        │              ▲
        │              └── + GET /v1/payments/{id} polling (every ~4s)
        │
        └── la clé API ne franchit JAMAIS cette ligne ─────────────────┘
```

### Cycle de vie requête → webhook

1. Le client lance le paiement auprès de **votre** backend.
2. Le backend envoie `POST /v1/payments` (clé Bearer). SandPay renvoie un objet
   `Payment` avec un `id` et un `status` initial (souvent `PENDING` — c'est une
   acceptation, pas un règlement).
3. Le client confirme sur son téléphone ; SandPay finalise la transaction.
4. SandPay envoie un webhook signé `payment.completed` en `POST` vers votre URL
   configurée.
5. Votre backend vérifie la signature, associe l'événement par `tx_id` et met à
   jour sa ligne. L'interrogation `GET /v1/payments/{id}` par polling est le
   mécanisme de repli résilient.

## 2. Le contrat

### Endpoints

<Note>
  **URL de base & chemin.** Sur le nom d'hôte public `https://api.sandpay.dev`,
  les endpoints se trouvent sous **`/v1/…`** (ex. `https://api.sandpay.dev/v1/payments`).
  Lorsque vous pointez vers une **instance SandPay déployée ou locale** (l'application
  Next.js), les mêmes routes sont servies sous **`/api/v1/…`** (ex.
  `${SANDPAY_BASE_URL}/api/v1/payments`). Supprimez tout slash final de votre URL
  de base, quelle que soit la situation — voir la [checklist](#9-checklist-dintegration).
</Note>

| Méthode | Endpoint                   | Description                                                                                                 | Auth   |
| ------- | -------------------------- | ----------------------------------------------------------------------------------------------------------- | ------ |
| `POST`  | `/v1/payments`             | Créer un paiement (simulé).                                                                                 | Bearer |
| `GET`   | `/v1/payments/{id}`        | Récupérer un paiement par son `id`.                                                                         | Bearer |
| `GET`   | `/v1/payments`             | Lister les paiements (pagination par curseur : `limit`, `country`, `operator`, `status`, `type`, `cursor`). | Bearer |
| `POST`  | `/v1/payments/{id}/refund` | Rembourser tout ou partie d'une collecte (versement → client). Voir [§5](#5-remboursements--décaissements). | Bearer |
| `POST`  | `/v1/disbursements`        | Décaissement libre (marchand → msisdn). Voir [§5](#5-remboursements--décaissements).                        | Bearer |
| `GET`   | `/v1/meta`                 | Version API + capacités (détection de fonctionnalités). Voir le [Changelog](/fr/integration-changelog).     | Aucune |
| `GET`   | `/v1/health`               | Sonde de santé.                                                                                             | Aucune |

<Note>
  **Rester à jour.** Chaque réponse `/v1/*` contient un en-tête
  `X-SandPay-Api-Version`, et `GET /v1/meta` retourne la version + les
  `capabilities` supportées + `min_sdk_version`. Détectez les fonctionnalités via
  `capabilities` plutôt qu'en supposant une version, et suivez le
  [Changelog d'intégration](/fr/integration-changelog) pour savoir ce qui a changé
  et ce que vous devez éventuellement faire.
</Note>

### Authentification

Envoyez la clé comme token Bearer sur chaque requête authentifiée. Côté serveur uniquement.

```http theme={null}
Authorization: Bearer sp_sk_test_a1b2c3d4e5f6...
```

Les clés `sp_sk_test_…` produisent des transactions simulées (aucune somme ne
circule). Voir [Authentification](/fr/authentication) pour la rotation et la
révocation.

### Corps de la requête (`POST /v1/payments`)

```json theme={null}
{
  "amount": 25000,
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "application": "zana",
  "description": "Premium upgrade",
  "scenario": "success"
}
```

| Champ         | Type   | Requis     | Notes                                                                                                                                                                                                      |
| ------------- | ------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `amount`      | entier | oui        | Montant brut dans la plus petite unité pratique de la devise (entier pour Mobile Money).                                                                                                                   |
| `currency`    | chaîne | oui        | Ex. `RWF`, `XOF`.                                                                                                                                                                                          |
| `operator`    | chaîne | non\*      | `mtn` / `orange` / `moov` / `airtel`. Détecté automatiquement depuis le `msisdn` si omis.                                                                                                                  |
| `country`     | chaîne | non\*      | ISO-2, ex. `RW`, `CI`. Détecté automatiquement depuis le `msisdn` si omis.                                                                                                                                 |
| `msisdn`      | chaîne | oui        | Numéro du payeur en **E.164** (`+250788123456`).                                                                                                                                                           |
| `reference`   | chaîne | oui        | Votre identifiant externe (votre clé d'idempotence). Le webhook **ne la renvoie pas**.                                                                                                                     |
| `application` | chaîne | **oui**    | Le slug de votre boutique — visible dans **Paramètres → Boutiques**. Chaque transaction est liée à une boutique.                                                                                           |
| `order_ref`   | chaîne | recommandé | **Votre identifiant de commande** — la clé qui relie un paiement, ses remboursements et toute charge récurrente. Se substitue à `reference` si omis. Visible sur la page **Commandes** du tableau de bord. |
| `order_url`   | chaîne | non        | URL publique vers la commande dans votre application — affichée comme lien "voir la commande" dans le tableau de bord.                                                                                     |
| `description` | chaîne | non        | Informatif ; stocké et renvoyé, sans effet sur le résultat.                                                                                                                                                |
| `scenario`    | chaîne | non        | Force un résultat déterministe (voir [Multi-environnement](#7-multi-environnement)).                                                                                                                       |

\* `country`/`operator` sont optionnels **uniquement** s'ils sont auto-détectables
depuis le préfixe MSISDN et que l'environnement correspondant est activé dans
les Paramètres.

<Tip>
  **Envoyez toujours `order_ref`.** C'est ainsi que SandPay regroupe les
  transactions liées — le paiement original, chaque remboursement et les charges
  récurrentes pour la même commande apparaissent tous sous une seule entrée sur la
  page **Commandes** du tableau de bord (et via `GET /v1/payments?order_ref=…` /
  le filtre `order`). Un remboursement hérite automatiquement du `order_ref` de sa
  collecte parente. Si vous l'omettez, SandPay utilise votre `reference` en
  substitution, mais un identifiant de commande stable (ex. l'id de votre panier
  ou abonnement) est la bonne clé de regroupement.
</Tip>

### Réponse — la ressource `Payment`

L'API renvoie du JSON en **camelCase**. (Le serializer dans
`frontend/lib/payments/serialize.ts` est la source de vérité pour cette forme.)

```json theme={null}
{
  "id": "TX_8K3M9F",
  "amount": 25000,
  "commission": 250,
  "netAmount": 24750,
  "customerTotal": 25000,
  "merchantAbsorptionPct": 100,
  "merchantShare": 250,
  "customerShare": 0,
  "commissionMode": "merchant",
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "description": "Premium upgrade",
  "scenario": "success",
  "status": "SUCCESS",
  "latencyMs": 1240,
  "createdAt": "2026-05-24T10:30:45.000Z",
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFUL",
    "financialTransactionId": "SIM_A1B2C3D4"
  }
}
```

| Champ                   | Type           | Signification                                                                                                                    |
| ----------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `id`                    | chaîne         | L'identifiant de transaction (`TX_…`). Stockez-le — le webhook y fait référence.                                                 |
| `amount`                | entier         | Le montant brut que vous avez demandé.                                                                                           |
| `commission`            | entier         | Commission opérateur = `round(amount × commission_bps / 10000)`.                                                                 |
| `netAmount`             | entier         | Ce que le **marchand reçoit**. **Réconciliez ici.** `null` (= brut) pour les anciennes lignes ; pas final tant que non terminal. |
| `customerTotal`         | entier         | Ce que le **SIM du client est débité** (`= netAmount + commission`).                                                             |
| `merchantAbsorptionPct` | int \| null    | % de la commission absorbée par le marchand (0–100). `null` pour les anciennes lignes.                                           |
| `merchantShare`         | entier         | Frais à la charge du marchand (`= amount − netAmount`).                                                                          |
| `customerShare`         | entier         | Frais à la charge du client (`= commission − merchantShare`).                                                                    |
| `commissionMode`        | chaîne \| null | Qui a absorbé les frais pour cette transaction : `"customer"` / `"merchant"`. `null` pour les anciennes lignes.                  |
| `currency`              | chaîne         | Telle qu'envoyée.                                                                                                                |
| `operator`              | chaîne         | Opérateur résolu.                                                                                                                |
| `country`               | chaîne         | Pays résolu.                                                                                                                     |
| `msisdn`                | chaîne         | MSISDN du payeur.                                                                                                                |
| `reference`             | chaîne         | Votre identifiant externe.                                                                                                       |
| `description`           | chaîne \| null | Telle qu'envoyée.                                                                                                                |
| `scenario`              | chaîne \| null | Le scénario forcé, ou `null`.                                                                                                    |
| `status`                | chaîne         | L'un des [11 statuts](#3-vocabulaire-des-statuts).                                                                               |
| `latencyMs`             | entier         | Latence opérateur simulée.                                                                                                       |
| `createdAt`             | chaîne         | ISO 8601.                                                                                                                        |
| `raw`                   | objet \| null  | Payload natif de l'opérateur. `_simulated: true` au premier niveau en sandbox. `null` pour les anciennes lignes — gérez ce cas.  |

<Tip>
  **Lisez l'identifiant de façon tolérante.** Lisez-le depuis `id`, mais
  acceptez `tx_id` / `txId` / `transaction_id` et un niveau d'imbrication
  (`data.id`, `payment.id`) pour qu'une différence de nommage ne soit jamais
  interprétée comme un échec.
</Tip>

## 3. Vocabulaire des statuts

Il y a exactement **11** statuts canoniques. Mappez-les tous pour qu'aucun ne
tombe dans un état par défaut non terminal.

| Statut                | Terminal ? | Signification                                                                                          |
| --------------------- | ---------- | ------------------------------------------------------------------------------------------------------ |
| `SUCCESS`             | ✅ oui      | Paiement effectué.                                                                                     |
| `PIN_INVALID`         | ✅ oui      | PIN incorrect.                                                                                         |
| `INSUFFICIENT_FUNDS`  | ✅ oui      | Solde inférieur au `customerTotal`.                                                                    |
| `TIMEOUT`             | ✅ oui      | L'opérateur a expiré — ou un prompt `PENDING` abandonné que le cron horaire a clôturé automatiquement. |
| `ACCOUNT_BLOCKED`     | ✅ oui      | SIM/compte bloqué.                                                                                     |
| `USER_CANCELLED`      | ✅ oui      | Le payeur a refusé le prompt. **Terminal.**                                                            |
| `UNKNOWN_MSISDN`      | ✅ oui      | Numéro absent du registre SIM.                                                                         |
| `LIMIT_EXCEEDED`      | ✅ oui      | Limite de transaction opérateur dépassée.                                                              |
| `SERVICE_UNAVAILABLE` | ✅ oui      | Opérateur en maintenance.                                                                              |
| `DUPLICATE_REFERENCE` | ✅ oui      | `reference` en doublon.                                                                                |
| `PENDING`             | ❌ non      | En cours — continuez le polling / attendez le webhook.                                                 |

<Warning>
  **`USER_CANCELLED` est terminal** — et c'est le piège le plus fréquent chez les
  intégrateurs. Un payeur qui refuse le prompt est un résultat *final*, pas une
  nouvelle tentative. Si vous le laissez mappé à "en attente", votre écran
  d'attente passe en polling jusqu'à expiration. Mappez-le à un état final
  "annulé".
</Warning>

`PENDING` est le seul statut non terminal. Tout autre statut est final et
déclenche le webhook `payment.completed`.

<Note>
  **Les prompts abandonnés expirent automatiquement.** Un paiement `PENDING` que
  le client ne confirme jamais (il n'a pas répondu au push USSD) est
  automatiquement clôturé en `TIMEOUT` par un cron horaire, après environ 60
  minutes. Vous recevez un webhook `payment.completed` avec le statut `TIMEOUT` —
  un prompt bloqué se résout donc toujours en résultat terminal de lui-même.
  Gérez `TIMEOUT` comme un échec terminal.
</Note>

## 4. Commission & règlement

SandPay applique une **commission opérateur** sur chaque paiement. Trois montants
importent :

* `amount` — ce que vous avez demandé (le brut).
* `customerTotal` — ce que le SIM du client est **débité** (`= netAmount + commission`).
* `netAmount` — ce que le marchand **reçoit**.

### Formule & invariant

```
commission     = round(amount × commission_bps / 10000)
merchantShare  = round(commission × merchantAbsorptionPct / 100)   (frais à la charge du marchand)
customerShare  = commission − merchantShare                         (frais à la charge du client)
netAmount      = amount − merchantShare
customerTotal  = netAmount + commission
```

**Invariant (toujours vérifié) :** `customerTotal === netAmount + commission`.

### Par défaut : le marchand absorbe tout

`merchant_absorption_pct` vaut **100** par défaut — le marchand absorbe la
totalité des frais. Le client paie alors exactement `amount`, et le marchand reçoit
`amount − commission`. À l'autre extrême (`0`), le client paie
`amount + commission` et le marchand reçoit `amount`. Les deux paramètres
(le taux `commission_bps` **et** la répartition de l'absorption) sont configurables
**par environnement** dans **Paramètres → Pays & opérateurs**.

<Warning>
  **Réconciliez sur `netAmount`, jamais sur `amount`.** `amount` est le montant
  brut affiché au client ; `netAmount` est ce qui atterrit réellement chez le
  marchand après la commission. Créditer votre grand livre marchand sur `amount`
  sur-crédite du montant de la commission. `netAmount` n'est pas final tant que
  la transaction est `PENDING` — ne comptabilisez le crédit qu'une fois la
  transaction terminale.
</Warning>

## 5. Remboursements & décaissements

Les endpoints ci-dessus modélisent tous des **collectes** (encaissement :
client → marchand). SandPay gère aussi la direction **sortante** (décaissement :
marchand → client) :

* **Remboursement** — annule tout ou partie d'une collecte `SUCCESS` antérieure.
  Lié à l'original via `parentTxId`.
* **Décaissement** — versement libre à un msisdn, sans lien avec une collecte
  antérieure (cashback, recharge, paiement).

Les deux **débitent le solde flottant marchand** (`merchant_balance`) et
**créditent le SIM destinataire**. **Aucune commission n'est prélevée sur un
versement** — le marchand a déjà payé la commission de l'opérateur lors de
l'encaissement d'origine ; l'opérateur ne la rembourse pas. Ainsi, un
remboursement intégral laisse le marchand dans le rouge du montant de la
commission d'origine (ce qui correspond au comportement réel des opérateurs).

Les versements sont persistés comme des transactions avec un `type` de `refund`
ou `disbursement` (les collectes ont `type: "collection"`). Filtrez-les avec
`GET /v1/payments?type=refund` / `?type=disbursement`.

### Rembourser un paiement

```
POST /v1/payments/{id}/refund
{ "amount": 500 }          // omettez `amount` pour un remboursement intégral du restant remboursable
```

```ts theme={null}
// Remboursement intégral
await sandpay.payments.refund("TX_8K3M9F");
// Remboursement partiel (en unités mineures)
await sandpay.payments.refund("TX_8K3M9F", {
  amount: 500,
  reference: "RFND-ORDER-42",
});
```

Le plafond remboursable est le montant brut `amount` original moins la somme des
remboursements réussis antérieurs. Un remboursement excessif renvoie
`422 exceeds_refundable` ; rembourser une transaction non `SUCCESS` ou non-collecte
renvoie `422`. Si le solde flottant marchand est insuffisant, le remboursement est
enregistré avec le statut `INSUFFICIENT_FUNDS` (aucune somme ne circule).

### Créer un décaissement

```
POST /v1/disbursements
{ "amount": 5000, "currency": "XOF", "operator": "mtn", "country": "CI", "msisdn": "+22507000000" }
```

```ts theme={null}
await sandpay.disbursements.create({
  amount: 5000,
  currency: "XOF",
  operator: "mtn",
  country: "CI",
  msisdn: "+22507000000",
});
```

Le destinataire est résolu dans le registre de clients de test de l'environnement +
sa politique de numéro inconnu, exactement comme pour les collectes :

| Destinataire                             | Résultat                      |
| ---------------------------------------- | ----------------------------- |
| Client de test actif                     | `SUCCESS` — SIM crédité       |
| Client de test bloqué                    | `ACCOUNT_BLOCKED`             |
| Msisdn inconnu (politique `reject`)      | `UNKNOWN_MSISDN`              |
| Msisdn inconnu (politique `passthrough`) | `SUCCESS` — aucun SIM crédité |
| Solde flottant marchand \< montant       | `INSUFFICIENT_FUNDS`          |

### Webhooks de versement

Les versements déclenchent le même webhook signé que les collectes, avec un nom
d'événement spécifique à la direction (le `status` terminal est toujours dans le
corps — vérifiez-le, pas seulement l'événement) :

| `type` de transaction | `event` du webhook       | En-tête `X-SandPay-Event` |
| --------------------- | ------------------------ | ------------------------- |
| `collection`          | `payment.completed`      | `payment.completed`       |
| `refund`              | `payment.refunded`       | `payment.refunded`        |
| `disbursement`        | `disbursement.completed` | `disbursement.completed`  |

Vous pouvez aussi rembourser une collecte `SUCCESS` directement depuis le tableau
de bord (page de détail de la transaction → **Rembourser cette transaction**),
pratique pour les tests manuels.

### Traçabilité — transmettez le `order_ref` source dans chaque versement

Pour faciliter les investigations, toutes les transactions liées à une commande
doivent partager le **même `order_ref`** (l'identifiant de la commande source) —
ainsi le paiement, ses remboursements et tout décaissement associé remontent tous
à une seule commande (page **Commandes** du tableau de bord,
`GET /v1/payments?order=<order_ref>`, le `order_ref` sur le webhook) :

* **Remboursement** — `order_ref` (et `order_url`) sont **hérités automatiquement**
  de la collecte parente. Ne les renvoyez pas ; remboursez simplement contre le bon
  identifiant de transaction original. (Persistez cet identifiant sur votre commande
  au moment du paiement.)
* **Décaissement** — n'a pas de parent, donc il n'hérite **de rien** : passez
  `order_ref` = la **même** valeur que sur la collecte originale. Si vous l'omettez,
  le versement utilisera son propre `reference` et ne sera pas regroupé avec la commande.
* **Récurrent / abonnement** — réutilisez un `order_ref` (ex. l'identifiant de
  l'abonnement) sur chaque charge.

Passez **`is_url_protected: true`** avec `order_url` lorsque la page de commande
dans votre application nécessite une permission spécifique (admin uniquement, pas
un lien public). SandPay affiche alors une **mention "⚠ URL protégée"** à côté du
lien sur les pages Commandes + détail de transaction, pour signaler à l'investigateur
qu'il aura besoin de ce droit. Vaut `false` par défaut. (Un remboursement hérite du
flag de son parent, comme `order_ref`/`order_url`.)

<Warning>
  N'utilisez jamais une nouvelle chaîne `refund-…` / `payout-…` comme clé de
  regroupement — c'est acceptable comme `reference` d'idempotence, mais `order_ref`
  doit rester l'identifiant de la **commande source**, sinon le versement flottera
  sans lien avec sa commande.
</Warning>

### Format de référence — `ORIGIN-OP-TS-CODE`

`reference` est **votre clé d'idempotence** (unique par organisation). SandPay
recommande une forme canonique unique pour la rendre cohérente et immédiatement
lisible + triable sur le tableau de bord. Capacité : `reference_format`.

```
ORIGIN-OP-TS-CODE     ex.  ZANA-PAY-20260531221015-8E2884A47B1C
```

| Segment  | Règle                                                                                                                                           |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `ORIGIN` | Le slug de votre application — majuscules, 2–12 caractères (ex. `ZANA`). SandPay utilise `SP`.                                                  |
| `OP`     | Opération en 3 lettres : `PAY` (collecte), `REF` (remboursement), `DIS` (décaissement), `ABO` (abonnement).                                     |
| `TS`     | Date UTC compacte `YYYYMMDDHHMMSS` — moment où la référence a été émise (triable).                                                              |
| `CODE`   | Hex majuscule à 12 caractères. **Aléatoire** pour une nouvelle tentative, ou **dérivé** d'un identifiant stable pour des reprises idempotentes. |

<Warning>
  **Idempotence : la chaîne ENTIÈRE doit être reproductible.** Pour une NOUVELLE
  tentative à chaque appel (ex. une nouvelle tentative de paiement pour une
  commande), utilisez un CODE aléatoire + TS courant. Pour une opération qui doit
  se dédupliquer lors d'une reprise (ex. un remboursement précis), rendez-la
  entièrement reproductible : un CODE **dérivé d'un identifiant stable**
  (`sha256(id)` → 12 premiers hex) **et** un `TS` stable (ex. l'horodatage de
  l'entité source) — sinon un remboursement retenté dont le TS/CODE ont dérivé
  déclencherait un double remboursement.
</Warning>

Le SDK (`@drwintech/sandpay` ≥ 0.3.0) fournit des helpers pour éviter de les
construire manuellement :

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

// Nouvelle tentative de paiement → CODE aléatoire + TS courant
const reference = buildReference({ origin: "ZANA", operation: "PAY" });
// Remboursement idempotent → CODE dérivé + TS stable (la date du paiement original)
const refundRef = buildReference({
  origin: "ZANA",
  operation: "REF",
  seed: refundId,
  at: originalPaymentCreatedAt, // Date | epoch-ms — maintient le TS stable lors d'une reprise
});
// Charge d'abonnement
const subRef = buildReference({ origin: "ZANA", operation: "ABO", seed: `${subId}:${period}` });
```

Construction manuelle (sans SDK) ? `TS` est l'UTC `YYYYMMDDHHMMSS` ; le CODE est
`crypto.randomBytes(6).toString("hex").toUpperCase()` (aléatoire) ou
`crypto.createHash("sha256").update(seed).digest("hex").slice(0,12).toUpperCase()`
(dérivé). Lorsque vous omettez `reference` sur un versement, SandPay en génère un
automatiquement dans ce format (`SP-REF-…` / `SP-DIS-…`, un remboursement héritant
du `ORIGIN` du parent).

<Note>
  C'est le format de la **`reference`** (clé d'idempotence) — indépendant du
  **`order_ref`** (clé de regroupement). Continuez à envoyer votre `order_ref`
  stable (l'identifiant de la commande source) quelle que soit la forme de
  `reference`. L'analyse est tolérante : les références pré-`TS`
  (`ORIGIN-OP-CODE`) sont toujours reconnues.
</Note>

## 6. Webhooks

SandPay envoie un événement `payment.completed` en `POST` vers votre URL configurée
dès qu'une transaction atteint un état terminal. Il s'agit d'un appel
**serveur-à-serveur** sans JWT — son authenticité repose sur la signature HMAC.

### Vérification de la signature

En-tête : `X-SandPay-Signature: sha256=<64-hex>` où le digest est
`HMAC-SHA256(secret, rawBody)` sur les **octets bruts exacts** reçus (avant tout
`JSON.parse`).

* **Vérifiez en temps constant.** Utilisez `crypto.timingSafeEqual` (ou
  WebCrypto + une comparaison en temps constant) — jamais un `===` sur la chaîne hex.
* **Échouez fermé.** Si le secret est absent ou si la signature ne correspond pas,
  rejetez avec `401`. Ne traitez jamais un corps non vérifié.
* **Vérifiez avant d'analyser.** Tout middleware qui re-sérialise le corps (ex.
  `bodyParser.json()` d'Express par défaut) invalide le digest. Capturez un buffer
  de corps brut sur la route webhook.

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

function verify(rawBody: string, header: string, secret: string): boolean {
  if (!header || !secret) return false; // échouer fermé
  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));
}
```

Sur Deno / Supabase Edge, utilisez WebCrypto (`crypto.subtle.importKey` + `sign`)
et une comparaison hex en temps constant.

<Warning>
  **Associez l'événement par `tx_id`, pas par votre `reference`.** Le webhook
  **ne renvoie pas** la `reference` que vous avez envoyée. Reliez l'événement à
  votre ligne par `tx_id` — l'`id` stocké à la création.
</Warning>

### Déployer le récepteur avec `--no-verify-jwt`

Sur Supabase, déployez la fonction webhook avec `--no-verify-jwt`
(`supabase functions deploy sandpay-webhook --no-verify-jwt`). SandPay ne transporte
pas de JWT ; protéger la route par vérification JWT entraînerait un 401 sur chaque
livraison. L'authenticité provient de la signature HMAC, que vous vérifiez vous-même.

### Corps du webhook

Le corps est en **snake\_case** et contient les mêmes champs de commission que la
réponse de création (source de vérité : `frontend/lib/webhooks/payload.ts`) :

```json theme={null}
{
  "event": "payment.completed",
  "tx_id": "TX_8K3M9F",
  "org_id": "org_123",
  "env_id": "env_456",
  "country": "RW",
  "operator": "mtn",
  "amount": "25000",
  "commission": "250.00",
  "net_amount": "24750.00",
  "customer_total": "25000.00",
  "merchant_absorption_pct": 100,
  "merchant_share": "250.00",
  "customer_share": "0.00",
  "commission_mode": "merchant",
  "currency": "RWF",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "status": "SUCCESS",
  "latency_ms": 1240,
  "created_at": "2026-05-24T10:30:45.000Z",
  "completed_at": "2026-05-24T10:30:46.652Z",
  "scenario": "success",
  "provider_tx_id": "SIM_A1B2C3D4",
  "description": "Premium upgrade",
  "raw": {
    "_simulated": true,
    "status": "SUCCESSFUL",
    "financialTransactionId": "SIM_A1B2C3D4"
  }
}
```

Renvoyez n'importe quel code `2xx` pour accuser réception. SandPay effectue
jusqu'à 5 tentatives avec un backoff exponentiel ; les `3xx` ne sont **pas**
suivis. Voir [Webhooks](/fr/webhooks) pour la politique de réessai et les helpers
`webhooks.verify` / `webhooks.parseEvent` de `@drwintech/sandpay`.

### Inngest : local vs cloud (critique pour le développement local)

SandPay envoie les webhooks **via Inngest**. Le déclenchement d'un webhook dépend
de l'endroit où SandPay lui-même s'exécute :

* **SandPay local** (`next dev`) : le webhook ne se déclenche **que si** le
  serveur de développement Inngest de SandPay est en cours d'exécution **et
  synchronisé** :

  ```bash theme={null}
  npx inngest-cli@latest dev -u http://localhost:3800/api/inngest
  ```

  avec `INNGEST_DEV=1` dans l'environnement de SandPay. Sans cela, les événements
  **sont mis en file d'attente mais aucun webhook n'est livré**. Ainsi, lorsque
  vous testez contre un SandPay local, traitez le **polling comme signal primaire**
  — le webhook est au mieux optionnel dans cette configuration.

* **SandPay cloud** (déployé + Inngest cloud) : les webhooks se déclenchent **de
  façon fiable et automatique** — utilisez-les comme signal primaire, avec le
  polling comme filet de sécurité.

### Exposer votre récepteur local avec un tunnel stable (domaine statique ngrok)

Les webhooks (et un SandPay cloud atteignant votre SandPay local) ont besoin d'une
**URL HTTPS publiquement accessible**. En développement local, exposez votre
handler avec **ngrok en utilisant un domaine statique réservé** — le niveau
gratuit inclut **un** domaine réservé, donc l'URL est **stable entre les
redémarrages** et vous configurez `SANDPAY_BASE_URL` / l'URL du webhook **une
seule fois**.

```bash theme={null}
# 1. Installez ngrok
#    macOS:    brew install ngrok
#    Linux:    sudo snap install ngrok   (ou téléchargez depuis ngrok.com/download)
#    Windows:  scoop install ngrok       (ou téléchargez le .exe)
#    npm:      npm install -g ngrok

# 2. Authentifiez-vous (une seule fois) — copiez votre token depuis
#    https://dashboard.ngrok.com/get-started/your-authtoken
ngrok config add-authtoken <VOTRE_AUTHTOKEN>

# 3. Réclamez votre domaine statique gratuit (une seule fois) sur
#    https://dashboard.ngrok.com/cloud-edge/domains  → "New Domain"
#    Vous obtenez un domaine réservé comme :  votre-nom.ngrok-free.app

# 4. Démarrez le tunnel avec votre domaine STATIQUE (l'URL ne change jamais)
ngrok http 3800 --domain=votre-nom.ngrok-free.app
#    (utilisez le port de ce que vous exposez : 3800 SandPay, 4000 Express/Hono,
#     8000 FastAPI, 3000 Next.js, 54321 fonctions Supabase)

# 5. Configurez l'URL de base / l'URL du webhook UNE SEULE FOIS — elle reste valide entre les redémarrages :
#    SANDPAY_BASE_URL=https://votre-nom.ngrok-free.app
```

<Tip>
  Le flag `--domain=<static>` est tout l'intérêt : l'URL est **la même à chaque
  fois**, donc vous configurez `SANDPAY_BASE_URL` / l'URL du webhook **une fois et
  n'y touchez plus jamais**. Le mode `ngrok http` sans `--domain` et les tunnels
  rapides `cloudflared` font tourner le sous-domaine à chaque redémarrage — vous
  forçant à reconfigurer le secret et coller à nouveau l'URL du webhook à chaque
  fois. Évitez-les pour les tests locaux répétés.
</Tip>

## 7. Multi-environnement

Chaque paire `opérateur + pays` est un **environnement** que vous configurez dans
**Paramètres → Pays & opérateurs**.

* **Détection automatique.** Au moment du paiement, vous ne choisissez pas
  l'opérateur manuellement — il est détecté automatiquement depuis le préfixe MSISDN
  du payeur (ex. `+25078…` / `+25079…` → MTN Rwanda). Vous pouvez toujours passer
  `country` / `operator` explicitement.

* **Activez l'environnement d'abord.** Si l'environnement résolu n'est pas activé
  dans les Paramètres, l'API renvoie une erreur (`env_not_found` / un 5xx pour un
  opérateur non configuré). Activez l'environnement opérateur/pays/devise avant
  d'envoyer des requêtes.

* **`test_clients` = le registre SIM.** Sans `scenario`, le résultat est piloté
  par les clients de test du marchand :

  * numéro inconnu → `UNKNOWN_MSISDN`
  * SIM bloqué → `ACCOUNT_BLOCKED`
  * solde inférieur au `customerTotal` → `INSUFFICIENT_FUNDS`
  * sinon → `PENDING` (le client confirme sur l'écran BigPhone)

  Créez des clients de test dans `/clients` d'abord, sinon tous les numéros
  retournent `UNKNOWN_MSISDN`.

* **Un `scenario` explicite force un résultat déterministe** (idéal pour la CI) :
  `success`, `pin_invalid`, `low_balance`, `timeout`, `blocked`, `cancelled`,
  `unknown_msisdn`, `limit_exceeded`, `maintenance`, `duplicate`. Un scénario
  explicite l'emporte toujours sur le registre SIM. Voir [Scénarios](/fr/scenarios).

## 8. Architecture recommandée

### Commande avant paiement

Créez la commande comme **`draft`**, appelez SandPay, puis :

* **L'init échoue** → **annulez le draft (supprimez-le)** et affichez l'erreur.
* **L'init réussit** → passez la commande à `initiated`/`pending`.
* **La confirmation terminale** (webhook ou polling) → **promouvez** à son état final.

Vous ne voulez jamais qu'une commande reste en suspens pour un paiement qui n'a
jamais démarré. Remontez les échecs d'init synchrones vers l'interface (message
d'erreur + retour à l'écran de paiement), pas seulement une annulation silencieuse.

### Webhook + polling

Utilisez **les deux**. Le webhook est une notification instantanée (il nécessite
une URL publique + le secret partagé + `--no-verify-jwt`) ; le polling
`GET /v1/payments/{id}` toutes les \~4s est le mécanisme de repli résilient pour
quand le webhook est retardé, perdu ou — en développement local —
[pas connecté du tout](#inngest--local-vs-cloud-critique-pour-le-développement-local).
Contre un SandPay cloud, le webhook est votre signal primaire et le polling est le
filet de sécurité ; contre un SandPay local, **le polling est effectivement primaire**.

```
Client ──(opérateur, téléphone, montant)──▶ VOTRE BACKEND  (initiate-payment)
                                        │ 1. créer commande en `draft`
                                        │ 2. POST /v1/payments (clé Bearer)
                                        │ 3a. échec → annuler (supprimer draft) → erreur
                                        │ 3b. ok   → tx "initiée" → retourner id au client
Client (écran d'attente) ◀──────────────┘
   ├─ résolution webhook (instantanée)         ── SandPay ──(webhook signé)──▶ VOTRE BACKEND
   └─ polling GET /v1/payments/{id} (~4s)         (payment-webhook): vérifier HMAC,
                                                  associer par tx_id, mettre à jour la ligne,
                                                  réconcilier le grand livre marchand sur netAmount
   terminal = SUCCESS / un échec / USER_CANCELLED → l'écran d'attente se résout
   tout le reste (PENDING) → continuer le polling
```

### Réconciliation

Quand une transaction est réglée, créditez votre grand livre marchand avec
**`netAmount`** (post-commission), jamais le montant brut `amount`. Ne comptabilisez
le crédit qu'une fois la transaction terminale et `netAmount` renseigné.

## 9. Checklist d'intégration

* [ ] L'URL de base **n'a pas de slash final** (`baseUrl.replace(/\/+$/, "")`) pour
  que `${baseUrl}/api/v1/payments` ne double pas en `//api/v1/payments`.
* [ ] `SANDPAY_API_KEY` + `SANDPAY_WEBHOOK_SECRET` sont des **secrets côté serveur**,
  jamais dans le code client, jamais commités.
* [ ] L'**environnement** opérateur/pays/devise est **activé** dans Paramètres →
  Pays & opérateurs (un environnement non configuré génère une erreur).
* [ ] L'identifiant de transaction est lu **de façon tolérante** (`id`, puis
  `tx_id` / `txId` / `transaction_id`, puis `data.id` / `payment.id` imbriqués).
* [ ] **Les 11 statuts sont tous mappés**, et **`USER_CANCELLED` est terminal**.
* [ ] Le récepteur webhook est déployé avec **`--no-verify-jwt`**, vérifie la
  signature **HMAC** en temps constant + échoue fermé, et **associe par `tx_id`**
  (pas par `reference`).
* [ ] L'URL webhook est collée dans le tableau de bord SandPay ; le secret est
  identique des deux côtés.
* [ ] Les versements marchands sont **réconciliés sur `netAmount`**, pas sur
  `amount`, et uniquement une fois terminal.
* [ ] **Commande avant paiement** : les commandes sont créées en `draft`, annulées
  en cas d'échec d'init, promues à la confirmation — pas de commandes orphelines.
* [ ] Un **mécanisme de polling** (`GET /v1/payments/{id}`, \~4s) sert de filet de
  sécurité au webhook.
* [ ] En **développement local**, le **serveur de développement Inngest** de SandPay
  est en cours d'exécution (`inngest-cli dev -u http://localhost:3800/api/inngest`
  * `INNGEST_DEV=1`) **ou** le polling est utilisé comme signal primaire.
* [ ] Testé de bout en bout : succès, **refus du payeur (`USER_CANCELLED`)**,
  fonds insuffisants et timeout.

## 10. Voir aussi

<CardGroup cols={2}>
  <Card title="Démarrage rapide" icon="rocket" href="/quickstart">
    Premier paiement en 10 minutes — Stack Builder ou manuel.
  </Card>

  <Card title="FAQ & conseils d'intégration" icon="circle-question" href="/integration-faq">
    Le guide de questions-réponses rapide.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/webhooks">
    Format du payload, vérification de la signature, réessais.
  </Card>

  <Card title="Scénarios" icon="list" href="/scenarios">
    Forcez n'importe quel résultat opérateur via le champ `scenario`.
  </Card>

  <Card title="SDK Node" icon="code" href="/sdks/node">
    @drwintech/sandpay — client typé + helpers webhook.
  </Card>

  <Card title="Changelog d'intégration" icon="timeline" href="/integration-changelog">
    Ce qui a changé, par version d'API — et quoi faire.
  </Card>

  <Card title="Mise à niveau & (ré)installation" icon="rotate" href="/integration-upgrade">
    Procédure pas-à-pas pour installer, réinstaller ou mettre à niveau depuis votre application.
  </Card>
</CardGroup>
