AGENTS.md en découlent. La FAQ d’intégration
est le guide de questions-réponses rapide ; ce guide-ci est le contrat complet.
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.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 payloadraw.
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.
Cycle de vie requête → webhook
- Le client lance le paiement auprès de votre backend.
- Le backend envoie
POST /v1/payments(clé Bearer). SandPay renvoie un objetPaymentavec unidet unstatusinitial (souventPENDING— c’est une acceptation, pas un règlement). - Le client confirme sur son téléphone ; SandPay finalise la transaction.
- SandPay envoie un webhook signé
payment.completedenPOSTvers votre URL configurée. - Votre backend vérifie la signature, associe l’événement par
tx_idet met à jour sa ligne. L’interrogationGET /v1/payments/{id}par polling est le mécanisme de repli résilient.
2. Le contrat
Endpoints
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.| 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. | Bearer |
POST | /v1/disbursements | Décaissement libre (marchand → msisdn). Voir §5. | Bearer |
GET | /v1/meta | Version API + capacités (détection de fonctionnalités). Voir le Changelog. | Aucune |
GET | /v1/health | Sonde de santé. | Aucune |
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 pour savoir ce qui a changé
et ce que vous devez éventuellement faire.Authentification
Envoyez la clé comme token Bearer sur chaque requête authentifiée. Côté serveur uniquement.sp_sk_test_… produisent des transactions simulées (aucune somme ne
circule). Voir Authentification pour la rotation et la
révocation.
Corps de la requête (POST /v1/payments)
| 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). |
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.
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.)
| 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. |
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. |
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. |
PENDING est le seul statut non terminal. Tout autre statut est final et
déclenche le webhook payment.completed.
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.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
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.
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
SUCCESSantérieure. Lié à l’original viaparentTxId. - Décaissement — versement libre à un msisdn, sans lien avec une collecte antérieure (cashback, recharge, paiement).
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
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
| 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 (lestatus 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 |
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(etorder_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 proprereferenceet ne sera pas regroupé avec la commande. - Récurrent / abonnement — réutilisez un
order_ref(ex. l’identifiant de l’abonnement) sur chaque charge.
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.)
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.
| 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. |
@sandpay/node ≥ 0.2.1) fournit des helpers pour éviter de les
construire manuellement :
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).
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.6. Webhooks
SandPay envoie un événementpayment.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.
crypto.subtle.importKey + sign)
et une comparaison hex en temps constant.
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) :
2xx pour accuser réception. SandPay effectue
jusqu’à 5 tentatives avec un backoff exponentiel ; les 3xx ne sont pas
suivis. Voir Webhooks pour la politique de réessai et les helpers
webhooks.verify / webhooks.parseEvent de @sandpay/node.
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é :avecINNGEST_DEV=1dans 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 configurezSANDPAY_BASE_URL / l’URL du webhook une
seule fois.
7. Multi-environnement
Chaque paireopé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 passercountry/operatorexplicitement. -
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. Sansscenario, 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)
/clientsd’abord, sinon tous les numéros retournentUNKNOWN_MSISDN. - numéro inconnu →
-
Un
scenarioexplicite 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.
8. Architecture recommandée
Commande avant paiement
Créez la commande commedraft, 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.
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.
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.
Réconciliation
Quand une transaction est réglée, créditez votre grand livre marchand avecnetAmount (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/paymentsne double pas en//api/v1/payments. -
SANDPAY_API_KEY+SANDPAY_WEBHOOK_SECRETsont 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, puistx_id/txId/transaction_id, puisdata.id/payment.idimbriqués). - Les 11 statuts sont tous mappés, et
USER_CANCELLEDest terminal. - Le récepteur webhook est déployé avec
--no-verify-jwt, vérifie la signature HMAC en temps constant + échoue fermé, et associe partx_id(pas parreference). - 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 suramount, 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/inngestINNGEST_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
Démarrage rapide
Premier paiement en 10 minutes — Stack Builder ou manuel.
FAQ & conseils d'intégration
Le guide de questions-réponses rapide.
Webhooks
Format du payload, vérification de la signature, réessais.
Scénarios
Forcez n’importe quel résultat opérateur via le champ
scenario.SDK Node
@sandpay/node — client typé + helpers webhook.
Changelog d'intégration
Ce qui a changé, par version d’API — et quoi faire.
Mise à niveau & (ré)installation
Procédure pas-à-pas pour installer, réinstaller ou mettre à niveau depuis votre application.