# Documentation développeur — XPay API v1

_Mise à jour : 10 juillet 2026_

XPay est une API de paiement mobile-first pour l'Afrique. Vous pouvez :

- Créer des demandes de paiement en USD ou CDF côté client
- Rediriger vos clients vers une page de checkout sécurisée hébergée par XPay
- Recevoir une notification (webhook) HTTP dès qu'un paiement est confirmé
- Poller le statut d'une transaction via un endpoint public
- Consulter soldes et historique dans votre tableau de bord

**Base URL de production :** `https://x-pay.dev`
**Format :** JSON UTF-8. Montants en **centimes** (1 USD = 100, 1 CDF = 100).
**Devises supportées :** `USD`, `CDF`.
**Fuseau horaire :** UTC, format ISO 8601.

---

## 1. Règle d'or des wallets

> **Seul le wallet USD est crédité, quelle que soit la devise payée par le client.**

Votre client peut être facturé en `USD` ou en `CDF`. Dans les deux cas, XPay règle le paiement en **USD uniquement** :

- **Wallet USD** : crédité par *tous* les paiements confirmés, en dollars. C'est votre solde réel de règlement.
- **Wallet CDF** : jamais crédité par un paiement provenant d'une intégration API ou d'un checkout. Il ne sert que pour les conversions et retraits manuels gérés directement par XPay.
- **Conversion automatique** : un paiement de 5 000 CDF est collecté en CDF côté client, puis converti au taux du jour et crédité en USD sur votre wallet.

Dans le webhook de confirmation, les champs `amount` et `currency` indiquent ce qui a été facturé au client. Les champs `settled_amount` et `settled_currency` représentent ce qui a été réellement crédité sur votre wallet USD — c'est ce que vous devez utiliser pour votre comptabilité et la livraison de votre service.

**Règlement marchand :** toujours `USD`. Un paiement CDF est converti au taux du jour et crédite uniquement le wallet USD.

### Frais & minimums (v1)

- **Dépôts : 0 % de frais.** Le montant réglé est crédité intégralement sur votre wallet USD (net = brut). Minimum 1 USD (ou équivalent CDF).
- **Retraits :** frais de 25 % en USD et 13 % en FC, minimum 10 USD ou 2 500 FC.
- Pour un dépôt, le webhook renvoie `settled_amount` — c'est exactement ce qui est crédité sur votre wallet USD. Aucun `fee_amount` n'est déduit en v1.
- Les frais de dépôt sont **verrouillés à 0 %** dans l'administration XPay.

---

## 2. Démarrage rapide

1. Créez votre compte sur https://x-pay.dev et confirmez votre email.
2. Générez une clé API dans **Plus → Intégrations API**. Copiez-la immédiatement.
3. Configurez votre URL webhook (HTTPS obligatoire).
4. Appelez `POST /api/public/payments` avec le montant et la devise.
5. Redirigez votre client vers la `payment_url` reçue.
6. Traitez le webhook `POST` reçu à la confirmation.

---

## 3. Authentification

Chaque requête doit inclure votre clé secrète dans :

- `X-API-Key: xpk_live_...`
- ou `Authorization: Bearer xpk_live_...`

Format : `xpk_live_` + 32 caractères.

```bash
curl https://x-pay.dev/api/public/payments \
  -H "X-API-Key: xpk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 500, "currency": "USD" }'
```

> ⚠️ Ne jamais exposer votre clé côté client (mobile, navigateur). Appelez toujours depuis votre backend.

---

## 4. Créer un paiement

**`POST https://x-pay.dev/api/public/payments`**

| Champ | Type | Requis | Description |
|---|---|---|---|
| `amount` | integer | oui | Montant en centimes (min : 1) |
| `currency` | string | oui | `USD` ou `CDF` |
| `description` | string | non | Libellé affiché au client (max 200 car.) |
| `customer_reference` | string | non | Identifiant interne, renvoyé dans le webhook |
| `customer_email` | string | non | Email du client (pré-remplissage) |
| `webhook_url` | string | non | Surcharge de l'URL webhook globale, pour ce paiement |
| `metadata` | object | non | JSON libre renvoyé tel quel dans le webhook |

**Réponse `201` :**

```json
{
  "order_id": "xpay_lz3k9x_a1b2c3d4",
  "transaction_id": "9f0e...",
  "status": "pending",
  "payment_url": "https://x-pay.dev/checkout/xpay_lz3k9x_a1b2c3d4",
  "amount": 500,
  "currency": "USD"
}
```

**Exemple :**

```bash
curl -X POST https://x-pay.dev/api/public/payments \
  -H "X-API-Key: xpk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 1500,
    "currency": "USD",
    "description": "Commande #1042",
    "customer_reference": "ORDER-1042",
    "customer_email": "client@example.com",
    "metadata": { "user_id": "u_123", "plan": "pro" }
  }'
```

---

## 5. Page de paiement (Checkout)

Ouvrez la `payment_url` reçue. Trois intégrations :

- **Nouvel onglet** — `window.open(payment_url, '_blank')`. L'onglet tente `window.close()` après succès (bloqué par certains navigateurs, prévoir fallback).
- **Redirection** — `window.location.href = payment_url`. Retour via `document.referrer` ou `return_url`.
- **Iframe** — XPay émet un `postMessage` à la page parente à la confirmation.

### Détection côté client (postMessage)

```js
window.addEventListener("message", (event) => {
  if (event.origin !== "https://x-pay.dev") return;
  const data = event.data;
  if (data && data.type === "xpay:payment" && data.status === "success") {
    // data.order_id contient l'identifiant
    // Rafraîchir votre UI / rediriger le client
  }
});
```

### Vérifier le statut (polling, non authentifié)

**`GET https://x-pay.dev/api/public/checkout-session/:order_id`**

```json
{
  "order_id": "xpay_lz3k9x_a1b2c3d4",
  "status": "pending",
  "amount": 1500,
  "currency": "USD",
  "checkout_url": "https://...",
  "created_at": "2026-07-10T12:30:00.000Z"
}
```

> Cet endpoint est **sans authentification** — ne renvoie aucune donnée sensible.

---

## 5 bis. Paiement direct Mobile Money (sans page de paiement)

Dans **12 pays africains**, XPay expose un flux **inline** : votre client choisit
son pays, son opérateur et saisit son numéro **sur votre propre page**, reçoit la demande de validation sur son
téléphone, et le paiement se confirme sans aucune redirection.

**Étapes**

1. Créez le paiement normalement (section 4) → vous obtenez `order_id`.
2. `POST /api/public/momo` avec l'opérateur + le numéro du client.
3. Interrogez `GET /api/public/momo?order_id=...` toutes les 5 secondes jusqu'à
   `success` ou `failed`.

### Initier la collecte

**`POST https://x-pay.dev/api/public/momo`**

```json
{
  "order_id": "xpay_lz3k9x_a1b2c3d4",
  "country": "CD",
  "operator": "AIRTEL_COD",
  "phone": "0991234567",
  "email": "client@exemple.com"
}
```

`country` est le code ISO-2 du pays payeur (par défaut `CD`).

Réponse :

```json
{ "status": "pending", "deposit_ref": "b1f0…-uuid" }
```

Statuts possibles : `pending` (validation en attente sur le téléphone),
`success`, `failed` (avec `message`).

Erreurs : `invalid_payload`, `unknown_order`, `unsupported_currency`,
`unsupported_operator`, `invalid_phone`, `amount_out_of_bounds`,
`initiate_failed` (502).

### Suivre le statut

**`GET https://x-pay.dev/api/public/momo?order_id=xpay_lz3k9x_a1b2c3d4`**

```json
{ "status": "pending" }
```

> Le crédit du wallet reste déclenché par la confirmation serveur (atomique et
> idempotente) : un même paiement n'est jamais crédité deux fois, même si vous
> interrogez le statut en parallèle du webhook.

### Pays et opérateurs disponibles en paiement direct

| `country` | Pays | Numéro | `operator` |
|---|---|---|---|
| `CD` | RD Congo (+243) | 9 chiffres | `VODACOM_MPESA_COD`, `AIRTEL_COD`, `ORANGE_COD` |
| `CM` | Cameroun (+237) | 9 chiffres | `MTN_MOMO_CMR`, `ORANGE_CMR` |
| `CI` | Côte d'Ivoire (+225) | 10 chiffres | `ORANGE_CIV`, `MTN_MOMO_CIV` |
| `SN` | Sénégal (+221) | 9 chiffres | `ORANGE_SEN`, `FREE_SEN` |
| `BJ` | Bénin (+229) | 10 chiffres | `MTN_MOMO_BEN`, `MOOV_BEN` |
| `CG` | Congo-Brazzaville (+242) | 9 chiffres | `AIRTEL_COG`, `MTN_MOMO_COG` |
| `GA` | Gabon (+241) | 8 chiffres | `AIRTEL_GAB` |
| `RW` | Rwanda (+250) | 9 chiffres | `MTN_MOMO_RWA`, `AIRTEL_RWA` |
| `KE` | Kenya (+254) | 9 chiffres | `MPESA_KEN` |
| `UG` | Ouganda (+256) | 9 chiffres | `MTN_MOMO_UGA`, `AIRTEL_OAPI_UGA` |
| `ZM` | Zambie (+260) | 9 chiffres | `MTN_MOMO_ZMB`, `AIRTEL_OAPI_ZMB`, `ZAMTEL_ZMB` |
| `SL` | Sierra Leone (+232) | 8 chiffres | `ORANGE_SLE` |

Le numéro est accepté en format local (`0991234567`) ou international
(`+243991234567`). Le montant en USD est automatiquement converti dans la
devise locale du payeur (CDF, XAF, XOF, RWF, KES, UGX, ZMW, SLE) au taux du jour ;
votre wallet XPay est toujours crédité en USD.

### Couverture XPay par pays

Pour les autres pays, le paiement passe par la page de paiement XPay
(section 5), qui présente automatiquement les moyens disponibles.


| Pays | Moyens de paiement | Devise |
|---|---|---|
| RD Congo | Airtel Money, Orange Money, M-Pesa | CDF, USD |
| Congo-Brazzaville | Airtel, MTN | XAF |
| Gabon | Airtel, Moov | XAF |
| Cameroun | MTN, Orange | XAF |
| Côte d'Ivoire | Orange, MTN, Moov, Wave | XOF |
| Sénégal | Orange, Wave, Free, Expresso | XOF |
| Bénin | MTN, Moov | XOF |
| Burkina Faso | Orange, Moov | XOF |
| Ghana | MTN, Vodafone, AirtelTigo | GHS |
| Nigeria | Cartes bancaires, transfert | NGN |
| Kenya | M-Pesa, Airtel | KES |
| Rwanda | MTN, Airtel | RWF |
| Ouganda | MTN, Airtel | UGX |
| Tanzanie | M-Pesa, Airtel, Tigo | TZS |
| Zambie | MTN, Airtel, Zamtel | ZMW |
| Sierra Leone | Orange, Africell | SLE |
| International | Cartes Visa / Mastercard | USD |

Quelle que soit la devise payée par le client, **votre wallet XPay est crédité
en USD** au taux du jour (voir section 1).

---


## 5 ter. Bouton XPay embarqué (`pay.js`)

Le moyen le plus rapide d'encaisser : **deux lignes de HTML**, aucun serveur,
aucune redirection. Le client reste sur votre site, la fenêtre de paiement
Mobile Money s'ouvre par-dessus votre page.

### 1. Récupérez votre clé publique de site

Dans XPay → **Plus → Intégrations API → Bouton de paiement embarqué**.
Elle ressemble à `xpb_live_…`.

> Cette clé est **publique** : elle peut apparaître dans le code source de
> votre site. Elle permet uniquement d'**ouvrir un paiement vers votre
> compte** — jamais de lire vos données ni de retirer de l'argent.
> Ne confondez pas avec votre **clé secrète** (`xpk_live_…`), qui reste
> exclusivement côté serveur.

### 2. Collez le bouton

```html
<script src="https://x-pay.dev/pay.js"></script>

<button
  data-xpay
  data-key="xpb_live_xxxxxxxx"
  data-amount="500"            <!-- montant EN CENTIMES : 500 = 5,00 $US -->
  data-currency="USD"          <!-- USD ou CDF -->
  data-description="Abonnement Pro"
  data-reference="order_123"   <!-- votre référence interne (optionnel) -->
  data-email="client@mail.com" <!-- optionnel -->
>
  Payer 5,00 $US
</button>
```

C'est tout. Tous les éléments portant `data-xpay` sont détectés
automatiquement, y compris ceux ajoutés dynamiquement après un appel à
`XPay.scan()`.

### 3. Réagir au résultat

**Via événements DOM :**

```js
document.querySelector('[data-xpay]').addEventListener('xpay:success', (e) => {
  console.log('Payé !', e.detail.orderId);
  window.location.href = '/merci';
});
```

Événements disponibles : `xpay:success`, `xpay:failed`, `xpay:close`.

**Via l'API JavaScript** (montant dynamique, panier, etc.) :

```js
XPay.open({
  key: 'xpb_live_xxxxxxxx',
  amount: panier.totalCents,
  currency: 'USD',
  description: 'Commande #' + panier.id,
  customerEmail: client.email,
  onSuccess: ({ orderId }) => window.location.href = '/merci?ref=' + orderId,
  onFailed:  ({ message }) => alert(message || 'Paiement non abouti'),
  onClose:   () => console.log('fenêtre fermée'),
  onError:   (err) => console.error(err),
});
```

Méthodes : `XPay.open(options)` (retourne une Promise), `XPay.close()`,
`XPay.scan(root)`, `XPay.bind(element)`.

### 4. Confirmez toujours côté serveur

`onSuccess` est un signal d'interface : il ne doit **jamais** être la seule
preuve d'un paiement (le navigateur du client n'est pas une source fiable).
Livrez la commande depuis votre **webhook marchand** (section 6) ou en
vérifiant le statut avec votre clé secrète :

```bash
curl https://x-pay.dev/api/public/payments/ORDER_ID \
  -H "Authorization: Bearer xpk_live_VOTRE_CLE_SECRETE"
```

### Endpoint utilisé par le script

`POST /api/public/embed-session` — authentifié par `public_key`, retourne
`{ order_id, amount, currency, embed_url }`. Vous n'avez normalement pas
besoin de l'appeler vous-même : `pay.js` s'en charge.

### Pays et opérateurs disponibles dans le bouton

Le bouton utilise le paiement direct Mobile Money (section 5 bis) : mêmes
12 pays, mêmes opérateurs, mêmes limites de montant. Le montant est converti
dans la devise locale du payeur au taux du jour, et **votre wallet est
crédité en USD**.

---


## 6. Webhooks marchand

Dès qu'un paiement est confirmé, XPay envoie une requête **POST JSON**. Votre endpoint doit répondre **HTTP 2xx en moins de 8 secondes**. Toute réponse hors 2xx ou hors délai est marquée `failed`.

### En-têtes envoyés

```
POST /votre/endpoint HTTP/1.1
Host: votre-domaine.com
Content-Type: application/json
X-XPay-Event: payment.success
```

### Payload

```json
{
  "event": "payment.success",
  "status": "success",
  "order_id": "xpay_lz3k9x_a1b2c3d4",
  "transaction_id": "9f0e2a10-7c8f-4b2e-9c1d-3a0f5e6d7b12",
  "customer_reference": "ORDER-1042",
  "amount": 50000,
  "currency": "CDF",
  "settled_amount": 22,
  "settled_currency": "USD",
  "exchange_rate": 2283.29,
  "metadata": { "user_id": "u_123" },
  "confirmed_at": "2026-07-10T12:34:56.000Z"
}
```

> **Règle métier XPay :** tous les paiements sont réglés en **USD**.
> Quand vous facturez en `CDF`, XPay collecte le montant côté client en CDF
> puis convertit au taux du jour et crédite uniquement votre wallet USD.
> Le wallet CDF n'est jamais crédité par un paiement d'intégration. Utilisez
> `settled_amount` / `settled_currency` pour votre comptabilité — c'est ce
> qui a réellement été crédité au wallet.

### Champs

| Champ | Type | Description |
|---|---|---|
| `event` | string | Toujours `payment.success` en v1 |
| `status` | string | `success` (v1 n'envoie pas encore `failed`) |
| `order_id` | string | Identifiant XPay unique — **clé d'idempotence** |
| `transaction_id` | string (uuid) | UUID interne XPay |
| `customer_reference` | string \| null | Votre référence à la création |
| `amount` | integer | Montant facturé au client, en centimes de la devise d'origine |
| `currency` | string | Devise facturée (`USD` ou `CDF`) |
| `settled_amount` | integer | Montant réellement crédité au wallet, en centimes USD |
| `settled_currency` | string | Toujours `USD` en v1 |
| `exchange_rate` | number \| null | Taux USD→CDF appliqué si `currency=CDF` ; `null` si `currency=USD` |
| `metadata` | object \| null | JSON libre transmis à la création |
| `confirmed_at` | string | Timestamp ISO 8601 UTC |


### Réponse attendue

```
HTTP/1.1 200 OK
Content-Type: application/json

{ "received": true }
```

> **Idempotence obligatoire.** Utilisez `order_id` comme clé unique et répondez `200 OK` pour tout événement déjà traité.

### Retry & livraisons échouées

- **v1** : une tentative automatique à la confirmation. En cas d'échec, la livraison est stockée avec le code HTTP et la réponse.
- Rejeu manuel disponible dans le dashboard (Transactions → détail).
- **v2 (à venir)** : retry exponentiel (1 min, 5 min, 30 min, 2 h, 12 h) jusqu'à 24 h.

### Exemple de handler (Node.js / Express)

```js
app.post("/webhooks/xpay", express.json(), async (req, res) => {
  const {
    event, status, order_id, customer_reference, amount, currency, metadata,
  } = req.body;

  if (event !== "payment.success" || status !== "success") {
    return res.status(200).end();
  }

  const already = await db.orders.findOne({ xpay_order_id: order_id });
  if (already?.paid) return res.status(200).end();

  const order = await db.orders.findOne({ reference: customer_reference });
  if (!order || order.amount !== amount || order.currency !== currency) {
    return res.status(200).end();
  }

  await db.orders.updateOne(
    { _id: order._id },
    { $set: { paid: true, xpay_order_id: order_id, paid_at: new Date() } },
  );

  return res.status(200).json({ received: true });
});
```

---

## 7. Sécurité

- **HTTPS obligatoire** pour l'URL webhook.
- **Clés secrètes côté serveur uniquement** — jamais dans du JS client ou une app mobile.
- **Idempotence** par `order_id`.
- **Vérifiez `amount` et `currency`** reçus avant de livrer.
- **Rotation** : régénérez la clé en cas de compromission, l'ancienne est révoquée immédiatement.

---

## 8. Codes d'erreur

Format :

```json
{ "error": { "code": "invalid_api_key", "message": "API key invalide ou révoquée" } }
```

| Code | HTTP | Description |
|---|---|---|
| `missing_api_key` | 401 | En-tête `X-API-Key` absent |
| `invalid_api_key` | 401 | Clé inconnue ou révoquée |
| `invalid_json` | 400 | Body JSON malformé |
| `invalid_payload` | 400 | Paramètre requis manquant ou invalide |
| `invalid_amount` | 400 | Montant doit être un entier positif |
| `invalid_currency` | 400 | Seuls `USD` et `CDF` sont supportés |
| `checkout_url_failed` | 500 | Impossible d'initialiser le paiement |
| `internal_error` | 500 | Erreur interne — réessayer |

---

## 9. Exemples de code

### Node.js

```js
import fetch from "node-fetch";

const res = await fetch("https://x-pay.dev/api/public/payments", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.XPAY_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: 1500,
    currency: "USD",
    description: "Commande #1042",
    customer_reference: "ORDER-1042",
  }),
});
const { payment_url } = await res.json();
return { redirect: payment_url };
```

### PHP

```php
<?php
$ch = curl_init("https://x-pay.dev/api/public/payments");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    "X-API-Key: " . getenv("XPAY_API_KEY"),
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "amount" => 1500,
    "currency" => "USD",
    "customer_reference" => "ORDER-1042",
  ]),
]);
$data = json_decode(curl_exec($ch), true);
header("Location: " . $data["payment_url"]);
```

### Python

```python
import os, requests

r = requests.post(
    "https://x-pay.dev/api/public/payments",
    headers={"X-API-Key": os.environ["XPAY_API_KEY"]},
    json={
        "amount": 1500,
        "currency": "USD",
        "customer_reference": "ORDER-1042",
    },
    timeout=10,
)
r.raise_for_status()
payment_url = r.json()["payment_url"]
```

---

## 10. Tests & Go-Live

1. Effectuez un paiement d'un petit montant (ex. 100 CDF).
2. Vérifiez que la transaction apparaît en statut **Réussi** dans le dashboard.
3. Contrôlez la réception du webhook (logs) — livraison en **200 OK**.
4. Confirmez l'exécution de votre logique métier (activation, réduction, livraison…).
5. En cas d'échec, rejouez manuellement depuis le dashboard.

**Support :** WhatsApp : +243 997 532 529

---

_Documentation XPay v1 — toute évolution rétrocompatible est ajoutée sans changement de version._
