Passer au contenu principal
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 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 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

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éthodeEndpointDescriptionAuth
POST/v1/paymentsCréer un paiement (simulé).Bearer
GET/v1/payments/{id}Récupérer un paiement par son id.Bearer
GET/v1/paymentsLister les paiements (pagination par curseur : limit, country, operator, status, type, cursor).Bearer
POST/v1/payments/{id}/refundRembourser tout ou partie d’une collecte (versement → client). Voir §5.Bearer
POST/v1/disbursementsDécaissement libre (marchand → msisdn). Voir §5.Bearer
GET/v1/metaVersion API + capacités (détection de fonctionnalités). Voir le Changelog.Aucune
GET/v1/healthSonde 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.
Authorization: Bearer sp_sk_test_a1b2c3d4e5f6...
Les clés 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)

{
  "amount": 25000,
  "currency": "RWF",
  "operator": "mtn",
  "country": "RW",
  "msisdn": "+250788123456",
  "reference": "ORDER-2026-A1",
  "application": "zana",
  "description": "Premium upgrade",
  "scenario": "success"
}
ChampTypeRequisNotes
amountentierouiMontant brut dans la plus petite unité pratique de la devise (entier pour Mobile Money).
currencychaîneouiEx. RWF, XOF.
operatorchaînenon*mtn / orange / moov / airtel. Détecté automatiquement depuis le msisdn si omis.
countrychaînenon*ISO-2, ex. RW, CI. Détecté automatiquement depuis le msisdn si omis.
msisdnchaîneouiNuméro du payeur en E.164 (+250788123456).
referencechaîneouiVotre identifiant externe (votre clé d’idempotence). Le webhook ne la renvoie pas.
applicationchaîneouiLe slug de votre boutique — visible dans Paramètres → Boutiques. Chaque transaction est liée à une boutique.
order_refchaînerecommandé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_urlchaînenonURL publique vers la commande dans votre application — affichée comme lien “voir la commande” dans le tableau de bord.
descriptionchaînenonInformatif ; stocké et renvoyé, sans effet sur le résultat.
scenariochaînenonForce 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.
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.

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.)
{
  "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"
  }
}
ChampTypeSignification
idchaîneL’identifiant de transaction (TX_…). Stockez-le — le webhook y fait référence.
amountentierLe montant brut que vous avez demandé.
commissionentierCommission opérateur = round(amount × commission_bps / 10000).
netAmountentierCe que le marchand reçoit. Réconciliez ici. null (= brut) pour les anciennes lignes ; pas final tant que non terminal.
customerTotalentierCe que le SIM du client est débité (= netAmount + commission).
merchantAbsorptionPctint | null% de la commission absorbée par le marchand (0–100). null pour les anciennes lignes.
merchantShareentierFrais à la charge du marchand (= amount − netAmount).
customerShareentierFrais à la charge du client (= commission − merchantShare).
commissionModechaîne | nullQui a absorbé les frais pour cette transaction : "customer" / "merchant". null pour les anciennes lignes.
currencychaîneTelle qu’envoyée.
operatorchaîneOpérateur résolu.
countrychaînePays résolu.
msisdnchaîneMSISDN du payeur.
referencechaîneVotre identifiant externe.
descriptionchaîne | nullTelle qu’envoyée.
scenariochaîne | nullLe scénario forcé, ou null.
statuschaîneL’un des 11 statuts.
latencyMsentierLatence opérateur simulée.
createdAtchaîneISO 8601.
rawobjet | nullPayload natif de l’opérateur. _simulated: true au premier niveau en sandbox. null pour les anciennes lignes — gérez ce cas.
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.

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.
StatutTerminal ?Signification
SUCCESS✅ ouiPaiement effectué.
PIN_INVALID✅ ouiPIN incorrect.
INSUFFICIENT_FUNDS✅ ouiSolde inférieur au customerTotal.
TIMEOUT✅ ouiL’opérateur a expiré — ou un prompt PENDING abandonné que le cron horaire a clôturé automatiquement.
ACCOUNT_BLOCKED✅ ouiSIM/compte bloqué.
USER_CANCELLED✅ ouiLe payeur a refusé le prompt. Terminal.
UNKNOWN_MSISDN✅ ouiNuméro absent du registre SIM.
LIMIT_EXCEEDED✅ ouiLimite de transaction opérateur dépassée.
SERVICE_UNAVAILABLE✅ ouiOpérateur en maintenance.
DUPLICATE_REFERENCE✅ ouireference en doublon.
PENDING❌ nonEn cours — continuez le polling / attendez le webhook.
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é”.
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

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

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
// 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" }
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 :
DestinataireRésultat
Client de test actifSUCCESS — 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 < montantINSUFFICIENT_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 transactionevent du webhookEn-tête X-SandPay-Event
collectionpayment.completedpayment.completed
refundpayment.refundedpayment.refunded
disbursementdisbursement.completeddisbursement.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) :
  • Remboursementorder_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.)
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.

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
SegmentRègle
ORIGINLe slug de votre application — majuscules, 2–12 caractères (ex. ZANA). SandPay utilise SP.
OPOpération en 3 lettres : PAY (collecte), REF (remboursement), DIS (décaissement), ABO (abonnement).
TSDate UTC compacte YYYYMMDDHHMMSS — moment où la référence a été émise (triable).
CODEHex majuscule à 12 caractères. Aléatoire pour une nouvelle tentative, ou dérivé d’un identifiant stable pour des reprises idempotentes.
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.
Le SDK (@sandpay/node ≥ 0.2.1) fournit des helpers pour éviter de les construire manuellement :
import { buildReference } from "@sandpay/node";

// 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).
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é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.
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.
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.

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) :
{
  "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 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é :
    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.
# 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
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.

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

8. Architecture recommandée

Commande avant paiement

Créez la commande comme draft, appelez SandPay, puis :
  • L’init échoueannulez 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. 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

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.