Passer au contenu principal
Les réponses courtes et éprouvées aux questions que les développeurs se posent en branchant SandPay dans une vraie application. Si vous ne lisez qu’une seule chose : réconciliez sur net_amount, associez les webhooks par tx_id, et traitez le polling comme votre filet de sécurité.
Cette page est le guide de questions-réponses rapide. Le contrat complet faisant autorité — endpoints, statuts, commission, webhooks et l’architecture recommandée — se trouve dans le Guide d’intégration. En cas de doute, le guide est la source de vérité.

FAQ

SandPay envoie les webhooks via Inngest. Le comportement diffère selon 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.
Par ailleurs, SandPay a également besoin d’une URL publiquement accessible pour votre récepteur. En développement local, exposez votre handler avec ngrok en utilisant un domaine statique réservé pour que l’URL soit stable et que vous configuriez l’URL webhook une seule fois :
ngrok http 4000 --domain=votre-nom.ngrok-free.app   # utilisez le port de votre backend
Réclamez le domaine statique gratuit une seule fois sur dashboard.ngrok.com/cloud-edge/domains. Le mode ngrok http sans --domain et les tunnels rapides cloudflared font tourner l’URL à chaque redémarrage — évitez-les pour les tests locaux répétés.Dans tous les cas, conservez un mécanisme de polling en secours (voir “Webhook vs polling” ci-dessous).
SandPay applique une commission opérateur sur chaque paiement. Trois montants importent :
  • amount — ce que vous avez demandé.
  • customer_total — ce que le SIM du client est débité (= net_amount + commission).
  • net_amount — ce que le marchand reçoit (= amount − merchant_share).
Par défaut (merchant_absorption_pct = 100), le marchand absorbe la totalité des frais : le client paie exactement amount, et le marchand reçoit amount − commission. Le taux (commission_bps) et la répartition de l’absorption sont configurables par environnement dans Paramètres → Pays & opérateurs.Voir Réconcilier les versements marchands pour ce qu’il faut enregistrer.
Sur votre backend, uniquement. La clé sp_sk_test_… ne doit jamais apparaître dans un bundle web, une application mobile ou tout code côté client — quiconque la voit peut débiter votre compte. Votre client appelle votre backend ; votre backend appelle SandPay avec la clé Bearer. Voir Authentification pour le contrat complet.
SandPay est multi-environnement. Chaque paire opérateur+pays est un environnement que vous configurez dans Paramètres → Pays & opérateurs. Au moment du paiement, vous ne choisissez pas l’opérateur manuellement — il est détecté automatiquement depuis le MSISDN du payeur (ex. +25078…/+25079… → MTN Rwanda). Pour ajouter un nouvel opérateur : activez son environnement dans les Paramètres, puis ajoutez l’entrée correspondante dans la table d’environnements de votre intégration (le kit de démarrage appelle cela ENABLED_ENVS) et redémarrez le backend.
Deux approches :
  • Forcer un résultat — passez un scenario explicite sur la requête de création (success, pin_invalid, low_balance, timeout, blocked, cancelled, unknown_msisdn, limit_exceeded, maintenance, duplicate). Déterministe, sans configuration préalable. Voir Scénarios.
  • Utiliser les test_clients comme registre SIM — omettez scenario et laissez les clients de test du marchand piloter le résultat : numéro inconnu → UNKNOWN_MSISDN, SIM bloqué → ACCOUNT_BLOCKED, SIM dont le solde est inférieur à customer_totalINSUFFICIENT_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.
Utilisez net_amount du webhook / du Payment, pas amount. amount est le montant brut affiché au client ; net_amount est ce qui atterrit réellement chez le marchand après la commission opérateur. Créditer votre grand livre marchand sur amount sur-crédite du montant de la commission. net_amount est null tant que la transaction est PENDING — ne comptabilisez le crédit qu’une fois la transaction terminale et net_amount renseigné.
Mappez les onze pour qu’aucun ne tombe dans un état par défaut non terminal :SUCCESS, PIN_INVALID, INSUFFICIENT_FUNDS, TIMEOUT, ACCOUNT_BLOCKED, USER_CANCELLED, UNKNOWN_MSISDN, LIMIT_EXCEEDED, SERVICE_UNAVAILABLE, DUPLICATE_REFERENCE, PENDING.
Piège courant : USER_CANCELLED est terminal. 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.
Un cron horaire le clôture en TIMEOUT (après ~60 min) et déclenche le webhook payment.completed. Ainsi, un prompt USSD abandonné se résout toujours en résultat terminal de lui-même — vous n’avez pas à expirer les commandes bloquées vous-même. Gérez TIMEOUT comme terminal.
Les deux. Le webhook vous donne une notification instantanée dès qu’une transaction atteint son état final ; 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.
SandPay gère les deux directions. Pour annuler une collecte SUCCESS antérieure, appelez POST /v1/payments/{id}/refund (sandpay.payments.refund(id, { amount }) — omettez amount pour un remboursement intégral). Pour envoyer de l’argent à un msisdn qui n’est pas lié à un paiement antérieur (cashback, recharge, paiement), appelez POST /v1/disbursements (sandpay.disbursements.create({ … })).Les deux débitent le solde flottant marchand et créditent le SIM destinataire. Aucune commission n’est prélevée sur un versement — le marchand l’a déjà payée sur la collecte d’origine, et 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). Les remboursements déclenchent un webhook payment.refunded ; les décaissements déclenchent disbursement.completed. Listez-les avec GET /v1/payments?type=refund / ?type=disbursement. Voir §5 du Guide d’intégration.
Non. 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 ; une transaction entièrement remboursée renvoie 422 fully_refunded. Vous pouvez émettre plusieurs remboursements partiels jusqu’à ce que le restant remboursable atteigne zéro. Si le solde flottant marchand est insuffisant, le remboursement est enregistré comme INSUFFICIENT_FUNDS et aucune somme ne circule.

Conseils

Commande avant paiement. Créez la commande en draft, appelez SandPay, et annulez le draft (supprimez-le) si l’init échoue. Vous ne voulez jamais qu’une commande reste en suspens pour un paiement qui n’a jamais démarré.
Lisez l’identifiant de façon tolérante. Lisez l’identifiant de transaction 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.
Associez le webhook par tx_id, pas par votre référence. 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).
Supprimez le slash final de votre URL de base. ${baseUrl}/api/v1/payments double en //api/v1/payments si SANDPAY_BASE_URL se termine par /. Normalisez avec baseUrl.replace(/\/+$/, "").
USER_CANCELLED est terminal. Mappez-le à un état final “annulé”, sinon votre écran d’attente passera en polling jusqu’à expiration lorsqu’un payeur refuse.
Réconciliez sur net_amount. Créditez le grand livre marchand avec net_amount (post-commission), jamais le montant brut amount.
En développement local, exposez votre récepteur ou reposez-vous sur le polling. SandPay a besoin d’une URL accessible pour livrer un webhook ; en développement local, exposez votre handler avec ngrok + un domaine statique réservé (ngrok http <port> --domain=votre-nom.ngrok-free.app — URL stable, configurez l’URL webhook une seule fois) et faites tourner le serveur de développement Inngest de SandPay, ou reposez-vous simplement sur le polling.

Voir aussi

Guide d'intégration

Le contrat canonique de bout en bout — la source de vérité.

Démarrage rapide

Premier paiement en 10 minutes — Stack Builder ou manuel.

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.