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

# FAQ & conseils d'intégration

> Réponses aux questions que les intégrateurs rencontrent réellement — webhooks, commission, réconciliation et pièges du développement local.

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

<Note>
  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**](/fr/integration-guide).
  En cas de doute, le guide est la source de vérité.
</Note>

## FAQ

<AccordionGroup>
  <Accordion title="Pourquoi mon webhook ne se déclenche-t-il pas en développement local ?">
    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é** :

      ```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](#quels-statuts-dois-je-gérer) 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** :

    ```bash theme={null}
    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](https://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).
  </Accordion>

  <Accordion title="Le montant débité diffère de ce que j'ai envoyé — pourquoi ?">
    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](#comment-réconcilier-les-versements-marchands)
    pour ce qu'il faut enregistrer.
  </Accordion>

  <Accordion title="Où dois-je mettre ma clé API ?">
    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](/fr/authentication) pour le contrat complet.
  </Accordion>

  <Accordion title="Comment gérer plusieurs opérateurs / pays ?">
    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.
  </Accordion>

  <Accordion title="Comment tester un échec précis (fonds insuffisants, annulé, …) ?">
    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](/fr/scenarios).
    * **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_total` → `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`.
  </Accordion>

  <Accordion title="Comment réconcilier les versements marchands ?">
    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é.
  </Accordion>

  <Accordion title="Quels statuts dois-je gérer ?">
    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`.

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

  <Accordion title="Que se passe-t-il pour un paiement PENDING que le client ne confirme jamais ?">
    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.
  </Accordion>

  <Accordion title="Webhook vs polling — lequel utiliser ?">
    **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](#pourquoi-mon-webhook-ne-se-déclenche-t-il-pas-en-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.
  </Accordion>

  <Accordion title="Comment rembourser un client, ou envoyer un versement ?">
    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](/fr/integration-guide#5-remboursements--décaissements).
  </Accordion>

  <Accordion title="Puis-je rembourser plus que le montant original ?">
    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.
  </Accordion>
</AccordionGroup>

## Conseils

<Tip>
  **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é.
</Tip>

<Tip>
  **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.
</Tip>

<Warning>
  **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).
</Warning>

<Tip>
  **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(/\/+$/, "")`.
</Tip>

<Warning>
  **`USER_CANCELLED` est terminal.** Mappez-le à un état final "annulé", sinon
  votre écran d'attente passera en polling jusqu'à expiration lorsqu'un payeur
  refuse.
</Warning>

<Tip>
  **Réconciliez sur `net_amount`.** Créditez le grand livre marchand avec
  `net_amount` (post-commission), jamais le montant brut `amount`.
</Tip>

<Tip>
  **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.
</Tip>

## Voir aussi

<CardGroup cols={2}>
  <Card title="Guide d'intégration" icon="book" href="/integration-guide">
    Le contrat canonique de bout en bout — la source de vérité.
  </Card>

  <Card title="Démarrage rapide" icon="rocket" href="/quickstart">
    Premier paiement en 10 minutes — Stack Builder ou manuel.
  </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>
</CardGroup>
