# NguePay — Guide d'intégration API

Ce guide décrit le contrat de l'API NguePay tel qu'il est déployé. Tous les exemples visent la sandbox (recette).
Version HTML : https://www.nguepay.com/docs/integration.html — ce fichier : https://www.nguepay.com/docs/integration.md

## Démarrage

| Élément | Sandbox (recette) | Production |
| --- | --- | --- |
| API REST | `https://api.sandbox.nguepay.com/api/v1` | `https://api.live.nguepay.com/api/v1` |
| Page de paiement hébergée | `https://pay.sandbox.nguepay.com/pay/{uuid}` | `https://pay.live.nguepay.com/pay/{uuid}` |
| Dashboard marchand (clés, webhooks) | `https://dashboard.sandbox.nguepay.com` | `https://dashboard.live.nguepay.com` |

**Clés API** — à générer dans le dashboard, rubrique Clés API. Chaque clé a deux parties :

- `sk_sandbox_…` (clé **secrète**) : appels **serveur à serveur** uniquement. Ne jamais l'exposer dans un navigateur ou une application mobile.
- `pk_sandbox_…` (clé **publique**) : appels **depuis le navigateur**. Elle n'est acceptée que si un **domaine autorisé** a été déclaré sur la clé dans le dashboard (sinon erreur `NGP015`, HTTP 403). Elle ne sert qu'à créer une session de paiement.

Les endpoints du **dashboard** (`/api/v1/merchant/webhook`, `/api/v1/keys`, profil…) ne prennent **aucune** de ces clés : ils exigent la session de connexion au dashboard (valable 12 h ; code de double authentification demandé si elle est activée sur le compte), et répondent `401 NGP001` à une clé API. De même, les coordonnées de notification (`notification_email`, `notification_phone`) ne se modifient pas avec une clé API (`403 NGP023`). Pour gérer vos webhooks **par l'API**, utilisez `/api/v1/webhook-endpoints` avec la clé secrète (section 3).

**Authentification** — un seul en-tête, quelle que soit la clé :

```
Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Accept: application/json
```

Clé absente, invalide ou révoquée → HTTP 401, `NGP001`. Marchand suspendu → HTTP 403, `NGP020`. Test rapide de la clé : `GET /api/v1/ping` (réponse `pong`).

**Montants** — envoyés en **XOF entiers** (`5000` = 5 000 F CFA), entre **100** et **5 000 000**. Dans les réponses de l'API, `amount_xof` est en XOF entiers et `amount` en centimes (`amount_xof` × 100) : utilisez `amount_xof`.

**Enveloppe des réponses** — toujours la même forme :

```json
{ "success": true, "data": { … }, "message": "Paiement initié." }
```

```json
{ "success": false, "message": "Clé API invalide.", "error_code": "NGP001" }
```

Les erreurs de validation renvoient HTTP 422, `"error_code": "NGP_VALIDATION"` et le détail par champ dans `errors`.

## 1. Créer un paiement

Deux façons de faire, selon qui choisit le moyen de paiement. Dans les deux cas, gardez l'`uuid` renvoyé : c'est la référence NguePay du paiement.

| | **A. Page de paiement NguePay** (recommandé) | **B. Paiement direct par opérateur** |
| --- | --- | --- |
| Qui choisit l'opérateur | Le client, sur la page NguePay | Votre serveur (`provider` obligatoire) |
| Endpoint | `POST /api/v1/checkout/sessions` | `POST /api/v1/payments` |
| Clé | publique `pk_sandbox_…` (navigateur, domaine déclaré) **ou** secrète `sk_sandbox_…` (depuis votre serveur uniquement, sans contrôle de domaine ; refusée — HTTP 401, `NGP001` — si l'appel porte un en-tête `Origin`, comme tout appel de navigateur : un serveur relais qui retransmet `Origin` doit le retirer) | secrète `sk_sandbox_…` |
| URL à ouvrir | `checkout_url` = `https://pay.sandbox.nguepay.com/pay/{uuid}` | `checkout_url` = page de l'opérateur (page de paiement Orange Money, ou Stripe Checkout pour la carte) ; en sandbox, souvent la page de simulation NguePay |
| Opérateur lancé | quand le client valide son choix | immédiatement |

### A. Page de paiement hébergée — `POST /api/v1/checkout/sessions`

| Champ | Type | Obligatoire | Règle |
| --- | --- | --- | --- |
| `amount` | entier | oui | XOF entiers, 100 à 5 000 000 |
| `reference` | chaîne | oui | votre référence de commande, ≤ 150 caractères, unique par marchand |
| `currency` | chaîne | non | `XOF` uniquement |
| `provider` | chaîne | non | `orange` ou `stripe` : présélectionne l'opérateur (`wave`, `mtn`, `pawapay` : temporairement indisponibles, refusés avec `NGP003`) |
| `phone` | chaîne | non | `+225` + 10 chiffres, ex. `+2250700000000` |
| `customer_email` | e-mail | non | ≤ 180 caractères |
| `description` | chaîne | non | ≤ 255 caractères |
| `return_url` | URL | non | où ramener le client après paiement, ≤ 500 caractères — renvoyée **telle quelle** (voir « Retour du client ») ; avec la clé publique, **même origine** que le domaine déclaré sur la clé |
| `metadata` | objet | non | vos données libres, renvoyées telles quelles ; 20 clés et 4 Ko (JSON encodé) au plus ; avec la clé publique, un `metadata.return_url` suit la même règle que `return_url` |

```bash
curl -X POST https://api.sandbox.nguepay.com/api/v1/checkout/sessions \
  -H "Authorization: Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"amount": 5000, "reference": "CMD-1001", "description": "Commande 1001", "return_url": "https://boutique.example/merci"}'
```

Réponse **201** (ou **200** si la même `reference` est rejouée à l'identique : vous recevez la session existante) :

```json
{
  "success": true,
  "data": {
    "uuid": "9d3f2b1a-6c4e-4f8a-b2d1-0e7c5a9f1234",
    "checkout_url": "https://pay.sandbox.nguepay.com/pay/9d3f2b1a-6c4e-4f8a-b2d1-0e7c5a9f1234",
    "expires_at": "2026-10-14T10:30:00+00:00",
    "status": "pending"
  },
  "message": "Session de paiement créée."
}
```

Redirigez le client vers `checkout_url`. La session expire **30 minutes** après sa création.

Avec la clé publique, un `return_url` (ou `metadata.return_url`) d'une autre origine que le domaine déclaré sur la clé — schéma, hôte et port — est refusé : HTTP 422, `NGP_VALIDATION`, détail sur le champ fautif.

### B. Paiement direct — `POST /api/v1/payments` (serveur uniquement)

Mêmes champs que A, avec ces différences :

- `provider` est **obligatoire** : `orange` (Orange Money CI) ou `stripe` (carte). `wave`, `mtn` et `pawapay` restent dans le contrat mais sont **temporairement indisponibles** : un paiement qui les demande est refusé (HTTP 422, `NGP003`).
- `phone` (≤ 20 caractères) est recommandé : le client paie sur la page Orange Money ou Stripe ouverte par `checkout_url`.
- `email` remplace `customer_email`.
- `webhook_url` (optionnel) : URL publique qui recevra les webhooks de **ce** paiement à la place des destinations du compte. Mêmes règles qu'une destination (section 3, « URL acceptées »).

```bash
curl -X POST https://api.sandbox.nguepay.com/api/v1/payments \
  -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"amount": 5000, "provider": "orange", "reference": "CMD-1002", "phone": "+2250700000089", "return_url": "https://boutique.example/merci"}'
```

Réponse **201** (**200** si rejouée à l'identique) :

```json
{
  "success": true,
  "data": {
    "transaction_id": "9d3f2b1a-7a10-4c2e-9f51-3b8d6e2c5678",
    "uuid": "9d3f2b1a-7a10-4c2e-9f51-3b8d6e2c5678",
    "status": "pending",
    "provider": "orange",
    "provider_code": "orange",
    "amount": 500000,
    "amount_xof": 5000,
    "refunded_amount_xof": 0,
    "currency": "XOF",
    "reference": "CMD-1002",
    "phone": "+225 07 *** ** 89",
    "checkout_url": "https://pay.sandbox.nguepay.com/pay/9d3f2b1a-7a10-4c2e-9f51-3b8d6e2c5678/simulate",
    "attempts": 0,
    "metadata": [],
    "failure_reason": null,
    "paid_at": null,
    "expires_at": "2026-10-14T10:30:00.000000Z",
    "created_at": "2026-10-14T10:00:00.000000Z",
    "updated_at": "2026-10-14T10:00:01.000000Z",
    "links": { "self": "/api/v1/transactions/9d3f2b1a-…", "refund": "/api/v1/transactions/9d3f2b1a-…/refund" }
  },
  "message": "Paiement initié."
}
```

Champs de la réponse :

| Champ | Type | Contenu |
| --- | --- | --- |
| `transaction_id`, `uuid` | uuid | identifiant NguePay ; `transaction_id` est un alias historique, toujours égal à `uuid` |
| `status` | chaîne | voir section 2 |
| `provider`, `provider_code` | chaîne \| null | opérateur ; `provider` est un alias historique ; `null` pour une session dont le client n'a pas encore choisi |
| `amount` | entier | montant en **centimes** (500000 = 5 000 XOF) |
| `amount_xof` | entier | montant en XOF entiers — celui à afficher |
| `refunded_amount_xof` | entier | cumul remboursé, en XOF |
| `currency` | chaîne | `XOF` |
| `reference` | chaîne | votre référence |
| `phone` | chaîne \| null | numéro **masqué** (`+225 07 *** ** 89`), `null` si non fourni |
| `checkout_url` | URL \| null | page de l'**opérateur** ; `null` pour une validation sur le téléphone, et pour une session tant que le client n'a pas choisi (ce n'est pas l'URL de la page NguePay) |
| `attempts` | entier | refus récupérables sur la page de paiement (numéro refusé, opérateur indisponible) ; `0` via cet endpoint |
| `metadata` | objet | vos données, telles qu'envoyées — `[]` (tableau vide) si vous n'en avez pas envoyé |
| `failure_reason` | chaîne \| null | motif d'échec |
| `paid_at` | date \| null | date d'encaissement |
| `expires_at`, `created_at`, `updated_at` | date | ISO 8601 UTC (`…000000Z`) |
| `links` | objet | `self` et `refund` : chemins **relatifs**, sans hôte |

Pour Stripe, la réponse peut aussi contenir `merchant` (`uuid`, `merchant_code`, `name` de votre compte) ; ce champ n'est pas renvoyé de façon constante, ne l'utilisez pas. `GET /api/v1/transactions/{uuid}` ajoute `logs`, l'historique des statuts. Référence complète : [objets renvoyés](/docs/api-reference.html#objets).

**Idempotence** — la `reference` est unique par marchand. La rejouer avec le **même montant** rend le paiement existant (HTTP 200, aucun second débit) ; avec un **montant différent**, elle est refusée (HTTP 422, `NGP005`).

**Retour du client** — le client est renvoyé sur `return_url` **exactement telle que vous l'avez envoyée** : NguePay n'y ajoute **aucun paramètre** (ni statut, ni uuid, ni référence, ni signature), et il n'existe pas de `success_url` / `cancel_url` côté marchand — les opérateurs ramènent toujours le client sur les pages NguePay, qui proposent `return_url`. Mettez-y vous-même ce qui vous permet de retrouver la commande, par exemple `https://boutique.example/retour?commande=CMD-1001`, puis retrouvez la transaction **depuis votre serveur** avec `GET /api/v1/transactions?reference=CMD-1001` ou `GET /api/v1/payments/{uuid}`. Sans `return_url`, `metadata.return_url` est utilisé s'il existe. Quand `checkout_url` vaut `null` (validation sur le téléphone), aucune page NguePay n'est affichée et `return_url` ne sert pas.

| Issue | Retour vers `return_url` |
| --- | --- |
| Paiement réussi | redirection automatique après 5 secondes, et bouton « Retour » |
| Paiement échoué ou expiré | bouton « Retour sur {marchand} » |
| Paiement annulé, ou client qui abandonne sur la page de choix | bouton « Retour au site marchand » |

Ce retour **ne prouve pas** le paiement, et il arrive aussi après un échec : confirmez toujours par le webhook ou par `GET /api/v1/payments/{uuid}` (section 2), avec l'`uuid` de la session — c'est le même identifiant.

## 2. Consulter le statut d'un paiement

`GET /api/v1/payments/{uuid}` avec la clé **secrète** renvoie le paiement complet, au même format que la réponse de création (section 1.B). Un `uuid` inconnu, ou appartenant à un autre marchand, renvoie HTTP 404.

```bash
curl https://api.sandbox.nguepay.com/api/v1/payments/9d3f2b1a-7a10-4c2e-9f51-3b8d6e2c5678 \
  -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -H "Accept: application/json"
```

À lire dans `data` : `status`, `amount_xof`, `paid_at`, `failure_reason`, `refunded_amount_xof`.

| `status` | Signification | Final ? | À faire côté marchand |
| --- | --- | --- | --- |
| `pending` | Créé, le client n'a pas encore payé | non | attendre |
| `processing` | Paiement lancé chez l'opérateur, en attente de confirmation | non | attendre (ne pas livrer) |
| `succeeded` | Argent encaissé (`paid_at` renseigné) | oui | livrer la commande |
| `failed` | Refusé, abandonné ou expiré — motif dans `failure_reason` (ex. `expired`, `insufficient_funds`, `initiation_failed`, `cancelled`) | oui | proposer un nouveau paiement |
| `expired` | Expiré (statut réservé ; l'expiration automatique produit aujourd'hui `failed` + `failure_reason: "expired"`) | oui | idem `failed` |
| `canceled` | Annulé par le marchand (`POST /api/v1/transactions/{uuid}/cancel`, sans webhook) ou par l'opérateur (webhook `payment.canceled`) | oui | — |

Sur la page de paiement, un **numéro refusé par l'opérateur** ou un **opérateur momentanément indisponible** ne clôt pas le paiement : le client corrige son numéro ou choisit un autre moyen, et le paiement reste `pending` (aucun webhook). Seul un refus définitif le passe en `failed`, avec le webhook `payment.failed`.

Un statut final ne change plus jamais. Un remboursement ne modifie pas `status` (le paiement reste `succeeded`) : il augmente `refunded_amount_xof`. Une confirmation d'opérateur d'un montant différent de celui du paiement n'est jamais créditée.

**Annulation** — `POST /api/v1/transactions/{uuid}/cancel` n'accepte qu'un paiement `pending`. NguePay interroge d'abord l'opérateur et refuse l'annulation (HTTP 422, message explicite) si l'opérateur indique que le client a payé, si une validation est en attente sur le téléphone du client, si l'opérateur est injoignable, ou si sa page de paiement hébergée ne peut pas être fermée. Dans ces cas, réessayez plus tard ou laissez le paiement expirer.

Recommandation : fiez-vous au webhook (section 3) et utilisez cet endpoint en complément — au retour du client, ou si aucun webhook n'est arrivé après quelques minutes.

## 3. Webhooks

NguePay envoie un `POST` JSON signé **HMAC-SHA256** sur `"{timestamp}.{corps brut}"`, avec le secret de la destination (`wh_…`). Le résultat est en **hexadécimal minuscule** dans l'en-tête `X-NguePay-Signature`.

**Déclarer une destination** — dans le dashboard (Webhooks), ou par l'API avec la clé secrète : `POST /api/v1/webhook-endpoints` avec `{"url": "https://boutique.example/webhooks/nguepay", "events": ["payment.succeeded", "payment.failed"]}`. Sans `events`, la destination reçoit tout. Jusqu'à 5 destinations par compte. Le secret `wh_…` n'est affiché **qu'une fois**, à la création ; `POST /api/v1/webhook-endpoints/{uuid}/rotate-secret` en génère un nouveau et `POST /api/v1/webhook-endpoints/{uuid}/test` envoie un événement de test.

**URL acceptées** — HTTPS obligatoire en production, port standard, aucun identifiant dans l'URL, et un hôte qui résout vers une adresse **publique** (adresses privées ou locales refusées). Les **redirections (3xx) ne sont pas suivies** : la livraison échoue. Déclarez l'URL finale.

| Événement | Quand |
| --- | --- |
| `payment.succeeded` | paiement encaissé |
| `payment.failed` | paiement refusé ou expiré |
| `payment.canceled` | paiement annulé côté opérateur |
| `refund.succeeded` | remboursement exécuté |
| `payout.succeeded` / `payout.failed` / `payout.canceled` | virement sortant conclu (y compris un virement Orange Money envoyé par l'équipe NguePay, ou refusé lors de la validation NguePay) |
| `webhook.test` | test déclenché depuis le dashboard ou l'API |

**En-têtes envoyés**

| En-tête | Contenu |
| --- | --- |
| `X-NguePay-Signature` | `hex(HMAC_SHA256(secret, timestamp + "." + corps_brut))` |
| `X-NguePay-Timestamp` | horodatage Unix en secondes **de cette tentative**, utilisé dans la signature |
| `X-NguePay-Event` | nom de l'événement, ex. `payment.succeeded` |
| `X-NguePay-Delivery` | identifiant unique de la livraison (identique à chaque relance, propre à chaque destination ; = `uuid` de `GET /api/v1/webhooks/{uuid}`) |
| `Content-Type` | `application/json` |

**Exemple de corps** — `payment.succeeded`. Attention : ici `amount` est en **centimes** (500000 = 5 000 XOF).

```json
{
  "event": "payment.succeeded",
  "data": {
    "transaction_id": "9d3f2b1a-7a10-4c2e-9f51-3b8d6e2c5678",
    "status": "succeeded",
    "amount": 500000,
    "currency": "XOF",
    "reference": "CMD-1002",
    "provider": "orange",
    "paid_at": "2026-10-14T10:02:41+00:00"
  }
}
```

Le corps ne contient que `event` et `data` : pas d'identifiant d'événement ni de date d'émission (ils sont dans les en-têtes). Les webhooks `payment.*` ne portent ni `failure_reason`, ni `metadata`, ni `amount_xof` — relisez la transaction si besoin. **Exception Stripe** : `payment.succeeded` et `payment.failed` d'un paiement Stripe ne portent que `transaction_id`, `status` et `reference`.

`refund.succeeded` porte `{"transaction_id", "refund_id", "amount"}` (`amount` : ce remboursement, en centimes) ; pour un remboursement fait depuis la console Stripe, s'y ajoutent `status` (statut de la **transaction**) et `reference`. Les événements `payout.*` (virements sortants, disponibles vers Orange Money ; certains passent d'abord par une validation de l'équipe NguePay — voir [Virements sortants](/docs/virements.html#validation)) portent `payout_id`, `reference`, `status`, `review` (`null` une fois le virement conclu), `amount`, `amount_xof`, `currency`, `provider`, `beneficiary` (masqué), `failure_reason` (dont `rejected_by_review` et `manual_execution_failed`), `awaiting_operator_funds`, `processed_at`. `webhook.test` porte `message`, `endpoint` (uuid de la destination) et `sent_at`. Détail : [Référence des événements](/docs/evenements.html#payloads).

**Vérifier la signature** — sur le corps **brut**, avant tout `JSON.parse`, et en comparaison à temps constant :

```javascript
// Node.js / Express : app.post('/webhooks/nguepay', express.raw({ type: 'application/json' }), handler)
const crypto = require('crypto');

function verifyNguepay(req, secret) {
  const signature = req.get('X-NguePay-Signature') || '';
  const timestamp = req.get('X-NguePay-Timestamp') || '';
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // anti-rejeu
  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex');
  return signature.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

```php
// PHP
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_NGUEPAY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_NGUEPAY_SIGNATURE'] ?? '';
$ok  = abs(time() - (int) $ts) <= 300
    && hash_equals(hash_hmac('sha256', $ts.'.'.$raw, $secret), $sig);
```

**Répondre et dédoublonner** — répondez **2xx en moins de 10 secondes**, puis traitez en tâche de fond. En cas d'erreur réseau, de timeout ou de réponse **5xx**, NguePay relance (3 tentatives : immédiate, puis 1 et 5 minutes plus tard ; les échecs restants sont rejoués périodiquement). Une réponse **3xx ou 4xx est définitive** : aucune relance. **Chaque relance est re-signée** : nouvel `X-NguePay-Timestamp`, nouvelle signature, corps identique — la fenêtre anti-rejeu de 300 s ne rejette donc pas une relance légitime. `X-NguePay-Delivery`, lui, reste le même. Un même événement peut arriver deux fois : dédoublonnez avec `X-NguePay-Delivery`, et ne livrez une commande qu'une fois par `transaction_id`.

**Override par paiement** — un `webhook_url` passé à `POST /api/v1/payments` reçoit les événements de ce paiement **à la place** des destinations. Il est signé avec le secret du webhook principal du compte, visible dans le dashboard (rubrique Webhooks).

## Codes d'erreur, tests sandbox et checklist

| Code | HTTP | Signification |
| --- | --- | --- |
| `NGP001` | 401 | Clé API absente, invalide ou révoquée, ou clé secrète envoyée depuis un navigateur |
| `NGP_VALIDATION` | 422 | Champs invalides — détail dans `errors` |
| `NGP003` | 422 / 502 | Opérateur indisponible |
| `NGP005` | 422 | Référence déjà utilisée pour un autre montant |
| `NGP007` | 429 | Trop de requêtes |
| `NGP008` | 404 | Ressource introuvable |
| `NGP009` | 422 | Refusé par l'anti-fraude (motif volontairement non détaillé) |
| `NGP010` | 422 | Opérateur non activé pour ce marchand |
| `NGP011` | 422 | Plafond mensuel dépassé (compte particulier : 3 000 000 XOF/mois) |
| `NGP015` | 403 | Clé publique sans domaine déclaré |
| `NGP020` | 403 | Compte marchand suspendu |
| `NGP023` | 403 | Action réservée au dashboard (coordonnées de notification modifiées avec une clé API) |

**Tests sandbox** : quand l'opérateur est simulé en sandbox, `checkout_url` mène à la page de simulation NguePay (`…/pay/{uuid}/simulate`), où vous choisissez l'issue — paiement réussi ou échoué — puis recevez le webhook correspondant. Quand la sandbox est branchée sur l'environnement de test de l'opérateur, `checkout_url` mène à sa page de test (pour la carte : cartes de test Stripe, ex. `4242 4242 4242 4242`). Aucun argent réel ne bouge.

**Moyens de paiement disponibles** (production et sandbox) :

| Moyen | `provider` | Statut |
| --- | --- | --- |
| Orange Money CI | `orange` | disponible — pas de remboursement par API (refus `NGP002`, montant rendu) |
| Carte bancaire (Stripe) | `stripe` | disponible |
| Wave CI | `wave` | bientôt disponible — refusé avec `NGP003` en attendant |
| MTN MoMo CI | `mtn` | bientôt disponible — refusé avec `NGP003` en attendant |

**Checklist d'intégration**

- Clés sandbox générées ; la clé secrète reste côté serveur ; domaine déclaré sur la clé publique
- Création du paiement avec une `reference` unique par commande ; `uuid` NguePay enregistré
- Destination webhook déclarée, secret `wh_…` stocké, signature vérifiée sur le corps brut
- Commande livrée uniquement sur `payment.succeeded` (ou `status = succeeded` via l'API), une seule fois
- Test réussi, test refusé et test d'abandon (paiement laissé 30 minutes) joués en sandbox
