Documentation
Référence de l'API UrbanPay
Cette documentation décrit l'intégration des encaissements, des versements et des remboursements UrbanPay : authentification, environnements Sandbox et Production, statuts, idempotence et webhooks signés. L'API est une API REST versionnée ; toutes ses réponses sont au format JSON.
Démarrage
L'API UrbanPay est une API REST. Toutes les routes sont préfixées par /v1 et servies sur https://api.urbanpay.net. Le même hôte sert la Sandbox et la Production : c'est votre paire de clés qui détermine l'environnement (voir Clés & environnements).
Toutes les réponses partagent une enveloppe JSON commune :
Enveloppe de réponse
| 1 | // Succès |
| 2 | { |
| 3 | "status": "success", |
| 4 | "code": "SUCCESS", |
| 5 | "message": "OK", |
| 6 | "data": { /* la ressource, ou un tableau pour les listes */ } |
| 7 | } |
| 8 | |
| 9 | // Erreur |
| 10 | { |
| 11 | "status": "error", |
| 12 | "code": "VALIDATION_ERROR", |
| 13 | "message": "Invalid request payload.", |
| 14 | "details": [ { "field": "amount", "message": "Too small: expected number to be >0" } ] |
| 15 | } |
Les listes ajoutent à côté de data un objet pagination (voir Consulter une transaction).
Une intégration typique suit trois étapes :
| Étape | Appel | But |
|---|---|---|
| 1 | POST /v1/auth/token | Échanger votre clé privée (backend) contre un jeton de session. |
| 2 | POST /v1/payins | Créer une transaction (avec un en-tête Idempotency-Key). |
| 3 | GET /v1/payins/:id + webhook | Suivre le statut final : par webhook signé, ou en consultant la transaction. |
Toutes les routes
Index des routes de l'API v1. Toutes exigent un jeton (voir Authentification) sauf l'échange de clé lui-même.
| Ressource | Route | Rôle |
|---|---|---|
| Auth | POST /v1/auth/token | Obtenir un jeton de session. |
| Référentiels | GET /v1/countries | Tout ce que votre compte peut utiliser en un appel : pays, moyens de paiement et opérateurs, avec leurs images. ?country=, ?phone=, ?process= et ?type=payout filtrent cette même liste et se combinent. |
GET /v1/currencies | Devises disponibles. | |
GET /v1/aggregators | Agrégateurs compatibles avec un pays. | |
GET /v1/restriction-options | Liste des pays, opérateurs et moyens de paiement, avec leur catégorie. | |
| Payin | POST /v1/payins | Créer un encaissement. |
GET /v1/payins | Lister les encaissements. | |
GET /v1/payins/:id | Détail d'un encaissement. | |
POST /v1/payins/:id/refresh | Re-vérifier le statut. | |
| Payout | POST /v1/payouts | Créer un versement. |
POST /v1/payouts/quote | Frais, total débité et solde disponible d'un payout, sans le créer (même corps que la création, sans Idempotency-Key). | |
GET /v1/payouts | Lister les versements. | |
GET /v1/payouts/:id | Détail d'un versement. | |
POST /v1/payouts/:id/refresh | Re-vérifier le statut. | |
POST /v1/payouts/:id/cancel | Annuler un versement qui n'est pas encore en cours de traitement. | |
| Remboursement | POST /v1/refunds | Créer un remboursement. |
GET /v1/refunds?payin_id= | Lister les remboursements d'un payin. | |
GET /v1/refunds/:id | Détail d'un remboursement. | |
POST /v1/refunds/:id/refresh | Re-vérifier le statut. | |
| Solde | GET /v1/balance | Solde de votre compte dans l'environnement de la clé. |
GET /v1/balance/entries | Mouvements du solde. | |
| Liens de paiement | POST /v1/payment-links | Créer un lien de paiement. |
GET /v1/payment-links | Lister les liens. | |
GET /v1/payment-links/:id | Détail d'un lien. | |
PATCH /v1/payment-links/:id | Modifier un lien. | |
DELETE /v1/payment-links/:id | Archiver un lien. |
Authentification
Votre compte dispose de deux clés aux rôles distincts. Ne les confondez pas et ne les envoyez jamais ensemble.
| Clé | Où l'utiliser | Rôle |
|---|---|---|
pk_… (publique) | Frontend / client | Identifie votre compte. Peut être exposée côté client. |
sk_… (privée) | Backend uniquement | Confidentielle. Seule cette clé permet d'obtenir un jeton d'accès. |
L'authentification se fait donc avec la clé privée seule, exclusivement côté serveur. Échangez-la contre un jeton d'accès de courte durée, puis transmettez ce jeton dans l'en-tête Authorization: Bearer <jeton> de chaque appel.
Obtenir un jeton
POST/v1/auth/token
La clé privée est envoyée dans l'en-tête Authorization, exactement comme un jeton. Le corps de la requête est vide.
| En-tête | Valeur | Obligatoire | Description |
|---|---|---|---|
Authorization | Bearer sk_… | Oui | Votre clé privée (sk_test_… pour la Sandbox, sk_live_… pour la Production). |
| 1 | curl -X POST https://api.urbanpay.net/v1/auth/token \ |
| 2 | -H "Authorization: Bearer sk_live_xxx" |
Réponse
| 1 | { |
| 2 | "status": "success", |
| 3 | "code": "SUCCESS", |
| 4 | "data": { |
| 5 | "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", |
| 6 | "expiresAt": "2026-01-01T13:00:00.000Z" |
| 7 | } |
| 8 | } |
Le jeton est valable 1 heure. Il n'y a pas de jeton de rafraîchissement : quand il expire, ré-appelez POST /v1/auth/token avec votre clé privée. Un jeton expiré renvoie TOKEN_EXPIRED (401), une clé invalide ou absente renvoie HEADER_INVALID (401).
Note
sk_test_… → Sandbox, sk_live_… → Production. Un en-tête Environment éventuel est ignoré : l'environnement lié au jeton fait toujours foi.Important
Clés & environnements
Chaque compte marchand dispose de deux paires de clés indépendantes :
| Paire | Clé publique | Environnement | Mouvement d'argent |
|---|---|---|---|
| Production | pk_live_… | prod | Réel |
| Sandbox | pk_test_… | dev | Aucun (simulation) |
Vos clés et vos secrets de signature webhook sont disponibles dans votre tableau de bord UrbanPay. Une clé privée n'est affichée qu'une seule fois, à sa création ou à sa régénération : conservez-la de façon sûre, UrbanPay n'en stocke qu'une empreinte. Régénérer une paire invalide immédiatement l'ancienne clé privée.
Note
Important
RESOURCE_FORBIDDEN (403). La Sandbox n'est pas concernée.Référentiels
Avant de créer une transaction, découvrez les pays, devises, opérateurs et moyens de paiement réellement disponibles. Ces routes sont en lecture seule et exigent un jeton.
| Route | Paramètres | Réponse (data) |
|---|---|---|
GET /v1/countries | Tous optionnels et combinables : country, phone, process, type (payin par défaut ou payout) | Toujours la même forme : [{ code, name, dialCode, flag?, paymentMethods: [{ code, name, category, requiredFields, icon?, operators? }] }] : les pays supportés, avec leurs moyens de paiement et opérateurs. Les paramètres ne font que filtrer (voir « Tout le catalogue en un appel » et « Filtrer le catalogue » plus bas). |
GET /v1/currencies | — | [{ code, name, symbol, decimals }]. decimals : nombre de décimales admises pour un montant dans cette devise (ISO 4217, ex. 0 pour XOF, 2 pour USD) |
GET /v1/aggregators | country (obligatoire), operator, process (optionnels) | [{ code, name }] : agrégateurs compatibles. |
country est un code ISO 3166-1 alpha-2 (ex. BJ). Pour GET /v1/countries, process est le code exact d'un moyen de paiement (ex. MOBILE, WAVECI) ; pour GET /v1/aggregators, c'est une catégorie (MOBILE, CARD, WAVE ou WALLET).
Codes à réutiliser dans vos transactions.
code d'un moyen de paiement (Mobile Money, Carte bancaire, Wave, Wallet...) est la valeur à envoyer dans process de POST /v1/payins et POST /v1/payouts. Le code d'un opérateur mobile money, dans operators[], est la valeur à envoyer dans network. Ces codes sont figés dès qu'ils sont utilisés (majuscules et chiffres, ex. MOBILE, WAVECI, MTNBJ) et propres à chaque plateforme : lisez-les dans cette route plutôt que de les recopier depuis cette page. L'appel sans paramètre suffit : il renvoie, pour chaque pays, tous les codes de moyens de paiement et d'opérateurs utilisables par votre compte.Tout le catalogue en un appel
Sans paramètre, GET /v1/countries renvoie en un seul appel tout ce que votre compte peut utiliser : les pays, pour chacun ses moyens de paiement et, pour le mobile money, ses opérateurs, avec leurs images. Cette réponse suffit pour construire votre propre écran de paiement.
- Seulement ce qui est routable. Un pays, un moyen de paiement ou un opérateur n'apparaît que s'il a au moins une route réelle, jugée comme à la création d'un payin : actif, et servi par au moins un agrégateur actif. Un moyen mobile money lié aux réseaux ne liste que ses opérateurs qui ont eux-mêmes une route ; sans aucun, il disparaît. Un pays sans aucun moyen utilisable disparaît aussi.
- Filtré par les restrictions de votre compte. Les pays, moyens de paiement et opérateurs que votre compte marchand n'autorise pas sont retirés : la liste ne contient que ce que vous pouvez réellement utiliser. Ces deux règles valent aussi avec les paramètres de filtre.
requiredFieldsliste les champs du corps de la requête que ce moyen de paiement exige, en plus de ceux que toute création exige (internal_ref,amount,process) :phone_numberpour le mobile money et Wave,emailpour la carte bancaire et les wallets,phone_numberpour tout payout (avec?type=payout), etcountrypour une carte ou un wallet proposé dans plusieurs pays (jamais pour le mobile money ni Wave, dont le pays se lit dans le numéro). Les champs facultatifs n'y figurent pas :networkreste facultatif pour le mobile money (l'opérateur est reconnu au préfixe du numéro).category(MOBILE,CARD,WAVE,WALLET) donne la famille du moyen de paiement ; pour savoir quoi envoyer, lisez plutôtrequiredFields.operatorsn'existe que pour un moyenMOBILElié aux réseaux télécom. Un moyen mobile money sans réseau (ex.CORISBJ), Wave, Carte bancaire et Wallet n'en ont pas.- Images :
flag(drapeau du pays),icon(image du moyen de paiement) etlogo(logo de l'opérateur) sont des URL temporaires, à afficher telles quelles et à relire plutôt qu'à stocker. Le champ est absent quand aucune image n'est définie. ?type=payout(par défautpayin) ne garde que ce qui sert un payout vers un bénéficiaire : les moyensMOBILEetWAVE, sur les routes qui acceptent le payout. Les moyens carte et wallet d'un payout vers vos propres coordonnées n'y figurent pas : lisez leur code dansGET /v1/restriction-options.
Note
| 1 | curl "https://api.urbanpay.net/v1/countries" \ |
| 2 | -H "Authorization: Bearer $ACCESS_TOKEN" |
| 3 | |
| 4 | # Seulement ce qui sert un payout mobile money ou Wave |
| 5 | curl "https://api.urbanpay.net/v1/countries?type=payout" \ |
| 6 | -H "Authorization: Bearer $ACCESS_TOKEN" |
Réponse (URL d'images abrégées)
| 1 | { |
| 2 | "status": "success", |
| 3 | "data": [ |
| 4 | { |
| 5 | "code": "BJ", |
| 6 | "name": "Bénin", |
| 7 | "dialCode": "+229", |
| 8 | "flag": "https://…/countries/BJ.png?X-Amz-Signature=…", |
| 9 | "paymentMethods": [ |
| 10 | { |
| 11 | "code": "CARD", |
| 12 | "name": "Carte bancaire", |
| 13 | "category": "CARD", |
| 14 | "requiredFields": ["email", "country"], |
| 15 | "icon": "https://…/payment-method-icons/….png?X-Amz-Signature=…" |
| 16 | }, |
| 17 | { "code": "CORISBJ", "name": "Coris Money", "category": "MOBILE", "requiredFields": ["phone_number"] }, |
| 18 | { |
| 19 | "code": "MOBILE", |
| 20 | "name": "Mobile Money", |
| 21 | "category": "MOBILE", |
| 22 | "requiredFields": ["phone_number"], |
| 23 | "icon": "https://…/payment-method-icons/….png?X-Amz-Signature=…", |
| 24 | "operators": [ |
| 25 | { "code": "MOOVBJ", "name": "Moov", "logo": "https://…/operators/….png?X-Amz-Signature=…" }, |
| 26 | { "code": "MTNBJ", "name": "MTN Bénin", "logo": "https://…/operators/….png?X-Amz-Signature=…" } |
| 27 | ] |
| 28 | } |
| 29 | ] |
| 30 | }, |
| 31 | { |
| 32 | "code": "CI", |
| 33 | "name": "Côte d'Ivoire", |
| 34 | "dialCode": "+225", |
| 35 | "paymentMethods": [ |
| 36 | { "code": "WAVECI", "name": "Wave", "category": "WAVE", "requiredFields": ["phone_number"] } |
| 37 | ] |
| 38 | } |
| 39 | ] |
| 40 | } |
Filtrer le catalogue
Les paramètres de GET /v1/countries ne changent jamais la forme de la réponse : ils réduisent l'arbre ci-dessus, et se combinent (un élément doit satisfaire tous les filtres envoyés). La réponse reste un tableau, même quand il ne contient qu'un pays.
| Paramètre | Effet |
|---|---|
country | Ne garde que ce pays (code ISO 3166-1 alpha-2, majuscules ou minuscules : bj vaut BJ). |
phone | Lit le pays dans le numéro et ne garde que ce pays, avec tous ses opérateurs : un numéro peut avoir été porté d'un opérateur à l'autre, le préfixe ne réduit donc pas la liste. Voir « Détecter le pays depuis un numéro » plus bas. |
process | Le code exact d'un moyen de paiement (ex. MOBILE, WAVECI, CORISBJ), comme dans process de POST /v1/payins, en respectant la casse. Dans chaque pays, seul ce moyen est gardé ; les pays qui ne l'ont pas disparaissent. Une catégorie (CARD, WAVE...) n'est acceptée que si c'est aussi le code d'un moyen. |
type | payin (par défaut) ou payout, comme ci-dessus. |
| 1 | # Un pays |
| 2 | curl "https://api.urbanpay.net/v1/countries?country=BJ" \ |
| 3 | -H "Authorization: Bearer $ACCESS_TOKEN" |
| 4 | |
| 5 | # Le pays d'un numéro, seulement ce qui sert un payout |
| 6 | curl -G "https://api.urbanpay.net/v1/countries" \ |
| 7 | --data-urlencode "phone=+22997000000" \ |
| 8 | --data-urlencode "type=payout" \ |
| 9 | -H "Authorization: Bearer $ACCESS_TOKEN" |
Réponse de ?phone=+22997000000&type=payout
| 1 | { |
| 2 | "status": "success", |
| 3 | "data": [ |
| 4 | { |
| 5 | "code": "BJ", |
| 6 | "name": "Bénin", |
| 7 | "dialCode": "+229", |
| 8 | "flag": "https://…/countries/BJ.png?X-Amz-Signature=…", |
| 9 | "paymentMethods": [ |
| 10 | { |
| 11 | "code": "MOBILE", |
| 12 | "name": "Mobile Money", |
| 13 | "category": "MOBILE", |
| 14 | "requiredFields": ["phone_number"], |
| 15 | "operators": [ |
| 16 | { "code": "MOOVBJ", "name": "Moov" }, |
| 17 | { "code": "MTNBJ", "name": "MTN Bénin" } |
| 18 | ] |
| 19 | } |
| 20 | ] |
| 21 | } |
| 22 | ] |
| 23 | } |
| Cas | Réponse |
|---|---|
| Le pays ou le moyen de paiement existe, mais rien n'est disponible pour votre compte (aucune route, restriction de votre compte, moyen qui ne sert pas au payout...) | 200 avec [] |
Filtres contradictoires (ex. country=CI avec un numéro béninois) | 200 avec [] |
country inconnu, désactivé ou archivé | 400 COUNTRY_UNSUPPORTED |
Aucun pays ne se lit dans phone (texte, indicatif inconnu, ou indicatif partagé que les premiers chiffres ne départagent pas) | 400 PHONE_ERROR |
| Le pays du numéro n'est pas dans le catalogue, n'est pas activé ou est archivé | 400 PHONE_UNSUPPORTED |
process n'est le code d'aucun moyen de paiement actif | 400 VALIDATION_ERROR, le même refus qu'à la création d'un payin |
Important
GET /v1/operators n'existe pas (404) : les opérateurs d'un pays se lisent dans operators[] de ses moyens mobile money, par exemple avec GET /v1/countries?country=….Détecter le pays depuis un numéro
Avec phone, vous préparez le formulaire de paiement dès que le payeur a saisi son numéro, sans lui demander de choisir un pays : la réponse ne contient que le pays du numéro. Envoyez son code dans country de vos transactions quand elles le demandent (carte ou wallet proposé dans plusieurs pays, par exemple), plutôt que de le déduire vous-même du numéro.
Note
+22901 donne le Bénin, l'indicatif seul (+229) ne suffit pas.Note
+2290197000000, 2290197000000 (chiffres seuls), 002290197000000 (préfixe 00), avec ou sans espaces, tirets, points ou parenthèses. Dans une URL écrite à la main, encodez le + en %2B ; un + non encodé est lu comme un espace, ce qui donne le même résultat puisque les chiffres seuls sont acceptés.Note
network reste facultatif à la création (l'opérateur est alors reconnu au préfixe du numéro), et l'envoyer permet de payer un numéro porté chez un autre opérateur.Payin (encaissement)
Créez un encaissement (mobile money, Wave, carte ou wallet) via POST /v1/payins. L'en-tête Idempotency-Key est obligatoire (voir Idempotence).
Endpoint
POST/v1/payins
Paramètres
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
internal_ref | string | Oui | Votre référence de commande : une chaîne, ou un entier. 100 caractères au plus, sans retour à la ligne ni caractère de contrôle. Un seul paiement en cours ou payé à la fois par référence, voir la note ci-dessous. |
amount | number | Oui | Montant, > 0, dans currency. Pas plus de décimales que la devise n'en admet (XOF : entier ; USD : 2), voir la note « Montant, devise et frais ». |
process | string | Oui | Le code du moyen de paiement (ex. MOBILE, WAVECI), lu dans GET /v1/countries. Sa catégorie décide des autres champs obligatoires, voir le tableau ci-dessous. Refusé avec checkout_mode: "hosted" : le payeur choisit sur la page. |
phone_number | string | selon le moyen | Téléphone du payeur, de préférence en format international (ex. +2290167000001). Obligatoire en mobile money et Wave. Enregistré et transmis au format international. En mode hébergé : pré-remplissage, modifiable par le payeur. |
email | string | selon le moyen | E-mail du payeur. Obligatoire pour une carte ou un wallet. En mode hébergé : pré-remplissage, modifiable par le payeur. |
currency | string (3) | Non | Devise de amount, code ISO 4217 (ex. XOF). Par défaut : la devise finale du paiement, celle que débite le fournisseur (devise du moyen de paiement, sinon du pays, sinon XOF). Voir la note « Montant, devise et frais ». |
country | string (2) | Non | Code ISO 3166-1 alpha-2, ex. BJ, CI. Par défaut, voir le tableau ci-dessous. |
network | string | Non | Code de l'opérateur mobile money (ex. MTNBJ), lu dans GET /v1/countries. Mobile money uniquement : reconnu au préfixe du numéro s'il est absent, ignoré pour les autres moyens. |
description | string | Non | Libellé libre. |
firstname | string | Non | Prénom du payeur. En mode hébergé : pré-remplissage, modifiable par le payeur. |
lastname | string | Non | Nom du payeur. En mode hébergé : pré-remplissage, modifiable par le payeur. |
otp | string | Non | Code à 4-10 chiffres que le payeur a généré pour ce montant, quand l'opérateur l'exige (ex. Orange Burkina). Sans lui, payment_url mène le payeur à une page UrbanPay qui le demande. Refusé avec checkout_mode: "hosted". Jamais stocké. |
expire_time | integer | Non | Durée de validité en secondes. Par défaut 1 800 (30 min), au plus 43 200 (12 h) ; au-delà : EXPIRED_UNSUPPORTED (400). |
checkout_mode | enum | Non | hosted : le payeur choisit pays, moyen de paiement et opérateur sur une page UrbanPay. Voir « Page de paiement hébergée » ci-dessous. |
metadata | object | Non | Vos propres données, en JSON libre (contenu de la commande, identifiant de votre client...). Conservées avec le payin et renvoyées telles quelles. |
callback | string | Non | URL de retour de ce payin : où le payeur est renvoyé après un paiement réussi. Remplace celle de votre compte pour ce payin, voir URL de retour. |
cancelback | string | Non | URL d'annulation de ce payin : où le payeur est renvoyé après un échec, une annulation ou une expiration. Remplace celle de votre compte pour ce payin. |
webhook | string | Non | URL de notification de ce payin : elle seule reçoit ses webhooks et ceux de ses remboursements. |
Important
process n'accepte pas les catégories CARD, WAVE ou WALLET : envoyez le code d'un moyen de paiement. Un code inconnu, inactif ou archivé est refusé (VALIDATION_ERROR, 400). Le corps n'accepte pas customer_id : le client se désigne par l'en-tête X-Customer-Reference, voir ci-dessous.Règles selon la catégorie du moyen de paiement
| Catégorie | Obligatoire | Pays retenu | Opérateur |
|---|---|---|---|
| Mobile money | phone_number (PHONE_REQUIRED) | country, sinon le pays du numéro | network, sinon reconnu au préfixe du numéro ; aucun opérateur reconnu : PHONE_UNSUPPORTED. network l'emporte sur le préfixe (numéro porté d'un opérateur à l'autre) mais doit être un opérateur du pays. |
| Wave | phone_number (PHONE_REQUIRED) | country, sinon le seul pays où ce moyen est proposé, sinon le pays du numéro. Si le compte Wave de votre payeur est dans un autre pays que son numéro, envoyez country. | Aucun, network ignoré. |
| Carte, Wallet | email (EMAIL_REQUIRED) | country, sinon le seul pays où ce moyen est proposé, sinon le pays du numéro | Aucun, network ignoré. |
Note
PHONE_ERROR quand aucun pays ne se lit dans le numéro, COUNTRY_UNSUPPORTED quand le moyen de paiement est proposé dans plusieurs pays et que ni country ni le numéro ne donnent le pays. Un numéro écrit en national (ex. 0197000000) reçoit l'indicatif du pays du paiement.Rattacher le payin à l'un de vos clients
Déclarez vos clients dans votre espace marchand (« Mes clients »), chacun avec une référence unique chez vous. Envoyez ensuite cette référence dans l'en-tête facultatif X-Customer-Reference de POST /v1/payins (page hébergée comprise) ou de POST /v1/payouts. Les exonérations et règles de routage propres à ce client s'appliquent alors.
| Cas | Résultat |
|---|---|
| En-tête absent ou vide | Transaction sans client. |
| Référence inconnue parmi vos clients | CUSTOMER_NOT_FOUND (404) |
| Client désactivé | CUSTOMER_INACTIVE (422) |
L'en-tête fait partie de ce que vérifie l'idempotence : réutiliser une Idempotency-Key avec une autre référence client donne IDEMPOTENCY_KEY_CONFLICT. Un payin issu d'un lien de paiement prend le client du lien.
Montant, devise et frais.
amount ne peut pas avoir plus de décimales que sa devise, selon la norme ISO 4217 (decimals dans GET /v1/currencies) : 1000.5 XOF est refusé, 1.23 USD accepté, 1.234 USD refusé. Chaque moyen de paiement a aussi un montant minimum et maximum. Dans les deux cas, l'appel est refusé avec AMOUNT_INVALID (400), avant tout contact avec un fournisseur. Selon la tarification de votre compte, des frais peuvent s'ajouter au montant : le payeur paie alors le montant plus les frais (amount_final). Sur certaines routes, c'est le fournisseur de paiement qui ajoute ses propres frais au moment du paiement (agregator_fee, indicatif) : le payeur les paie en plus, chez le fournisseur, et ils ne sont jamais déduits de votre crédit. Le fournisseur débite le payeur dans la devise finale : la devise du moyen de paiement, sinon celle du pays du paiement, sinon XOF ; c'est la même quel que soit le fournisseur retenu. Votre montant y est converti au besoin, et lu dans cette devise si vous n'envoyez pas currency. amount_charged et currency_charged (dans GET /v1/payins/:id et la liste) donnent ce qui a réellement été débité. Frais, commission, bornes et solde restent calculés en XOF. Une fois le payin validé, votre solde est crédité de net_amount = montant − commission (exemple : 10 000 XOF, commission 1,8 % → 9 820, que les frais soient ceux d'UrbanPay ou du fournisseur).Taux de change.
amount_charged ne change plus, même si le taux évolue ensuite. Sources des taux : currency-api et Rates By Exchange Rate API.Payin exonéré.
X-Customer-Reference, est exonéré de frais pour ce type de moyen de paiement, le payeur paie exactement amount et c'est vous qui supportez les frais : ils sont déduits du montant crédité sur votre solde. Exemple : 10 000 XOF avec 3 % de frais et 1,8 % de commission, le payeur paie 10 000 et votre solde est crédité de 9 520. Le champ fee_bearer des réponses dit qui a payé les frais : payer ou merchant. L'exonération ne s'applique jamais à un payout. Exception : sur une route où le fournisseur ajoute ses propres frais, le payeur les paie quand même chez lui (UrbanPay ne peut pas les supprimer). La valeur fixe éventuelle de la route est elle aussi à votre charge.Une commande, un paiement.
internal_ref est en attente ou payé (même compte, même environnement), une nouvelle création avec cette référence est refusée avec DUPLICATE_INTERNAL_REF (409), même avec une autre Idempotency-Key. Le détail de l'erreur donne l'id_transaction du payin existant : suivez-le plutôt que d'en créer un autre. Une fois ce payin annulé, expiré ou remboursé, la référence est de nouveau libre et la commande peut être repayée. Espaces de début et de fin retirés ; un nombre doit être entier.Réponse 409 : référence déjà en cours ou payée
| 1 | { |
| 2 | "status": "error", |
| 3 | "code": "DUPLICATE_INTERNAL_REF", |
| 4 | "message": "This internal_ref already has a payment in progress or paid. It can be reused once that payment is cancelled, expired or refunded.", |
| 5 | "details": [{ "field": "internal_ref", "id_transaction": "b3f1c9e2-..." }] |
| 6 | } |
Métadonnées.
metadata est un objet JSON de votre choix. UrbanPay le conserve avec le payin et vous le renvoie tel quel, sans le lire ni le modifier : dans la réponse de création, dans GET /v1/payins/:id, dans la liste et dans le corps de chaque webhook payin.*. Il n'est jamais transmis au fournisseur de paiement ni affiché au payeur. Le champ est absent des réponses quand vous n'en avez pas envoyé. Limites : un objet (pas un tableau ni une valeur seule), 16 Ko au plus, 20 niveaux d'imbrication au plus. Au-delà, l'appel est refusé avec VALIDATION_ERROR. L'ordre des clés n'est pas garanti au retour, et un entier de plus de 15 chiffres doit être passé en chaîne pour ne pas perdre en précision côté JavaScript.Champs inconnus.
callback_url au lieu de callback) n'a donc aucun effet. N'envoyez que les champs documentés.| 1 | curl -X POST https://api.urbanpay.net/v1/payins \ |
| 2 | -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 3 | -H "Idempotency-Key: cmd-2026-00123" \ |
| 4 | -H "Content-Type: application/json" \ |
| 5 | -d '{ |
| 6 | "internal_ref": "CMD-2026-00123", |
| 7 | "amount": 5000, |
| 8 | "currency": "XOF", |
| 9 | "process": "MOBILE", |
| 10 | "country": "BJ", |
| 11 | "phone_number": "+2290167000001", |
| 12 | "network": "MTNBJ" |
| 13 | }' |
Réponse
| Champ | Type | Description |
|---|---|---|
id_transaction | string | Identifiant UrbanPay du payin (à utiliser pour la consultation, le rafraîchissement, le remboursement). |
internal_ref | string | Votre référence. |
external_id / external_ref | string | Identifiant principal de la transaction chez le fournisseur, et sa référence. Absents tant que le fournisseur n'a pas répondu. external_ref reprend external_id quand le fournisseur ne donne pas de seconde référence. |
amount | number | Montant demandé. |
currency | string | Devise d'origine. |
status | string | Statut de cycle de vie (en minuscules). Voir Statuts. |
code | string | Code normalisé (majuscules). Voir Statuts. |
message | string | Le message du code, en anglais. Ne branchez pas votre logique dessus. |
payment_url | string | Présent uniquement tant que le payin est en attente (status pending) sur un flux avec redirection / page hébergée. Absent une fois la transaction résolue (validée, annulée, expirée ou remboursée). |
expires_at | string | Date d'expiration (ISO 8601), si applicable. |
metadata | object | Vos données, telles que vous les avez envoyées. Absent si vous n'en avez pas envoyé. |
payment_method | string | Le code du moyen de paiement envoyé dans process. Absent pour une page hébergée où le payeur n'a pas encore choisi, et pour un payin antérieur au 28 septembre 2026. |
fee_bearer | string | payer (le payeur paie les frais) ou merchant (payin exonéré, frais UrbanPay déduits de votre crédit). |
customer_id / customer_reference | string | Le client rattaché par X-Customer-Reference : son identifiant UrbanPay et sa référence. customer_reference vaut null sans client. |
Réponse 200
| 1 | { |
| 2 | "status": "success", |
| 3 | "code": "SUCCESS", |
| 4 | "message": "Payin created", |
| 5 | "data": { |
| 6 | "customer_reference": null, |
| 7 | "id_transaction": "b3f1c9e2-...", |
| 8 | "internal_ref": "CMD-2026-00123", |
| 9 | "amount": 5000, |
| 10 | "currency": "XOF", |
| 11 | "status": "pending", |
| 12 | "code": "WAITING_CUSTOMER_PAYMENT", |
| 13 | "message": "Waiting for the customer's confirmation.", |
| 14 | "payment_method": "MOBILE", |
| 15 | "fee_bearer": "payer" |
| 16 | } |
| 17 | } |
La réponse est immédiate : le paiement lui-même est asynchrone. Suivez son issue par webhook ou via GET /v1/payins/:id.
Page de paiement hébergée.
checkout_mode: "hosted", vous n'envoyez que la commande : internal_ref, amount et, au besoin, currency (par défaut la devise de votre compte marchand), description, metadata, expire_time et l'en-tête X-Customer-Reference. Aucun fournisseur n'est contacté à la création : la réponse renvoie un payment_url vers une page UrbanPay où le payeur choisit son pays (parmi ceux que votre compte accepte), puis son moyen de paiement, puis son opérateur si besoin, et saisit son numéro. phone_number, email, firstname et lastname, si vous les envoyez, pré-remplissent la page ; le payeur peut les modifier. Le fournisseur de paiement n'est jamais choisi par le payeur : UrbanPay le route. La transaction reste en pending / WAITING_PAYER_INPUT jusqu'à l'action du payeur. Le montant affiché au payeur sur cette page est celui qu'il paie réellement, frais compris ; il reste indicatif tant que le payeur n'a pas choisi son moyen de paiement, qui peut le faire varier.Page hébergée.
process, country et network sont refusés (VALIDATION_ERROR, 400) : c'est le payeur qui les choisit. integration_channel_id n'est plus lu sur les payins, payouts et liens de paiement : il est ignoré.Payout (versement)
Versez vers un bénéficiaire (salaire, commission, remboursement fournisseur) via POST /v1/payouts. Même contrat que le Payin, avec l'en-tête Idempotency-Key obligatoire. Les champs phone_number, firstname et lastname décrivent le bénéficiaire. Il n'y a ni expire_time ni checkout_mode. Un payout est un versement mobile money ou Wave vers un bénéficiaire, ou un versement sur votre propre carte bancaire ou votre propre wallet (voir Payout par carte ou wallet), sur le moyen de paiement désigné par process. Le client se désigne, comme pour un payin, par l'en-tête X-Customer-Reference.
Important
process est obligatoire : le code d'un moyen de paiement mobile money, Wave, carte ou wallet (ex. MOBILE, WAVE, CARD ; liste complète avec la catégorie : GET /v1/restriction-options). Absent, vide, inconnu ou désactivé : VALIDATION_ERROR (400). Le versement n'est routé que sur ce moyen, sans jamais basculer vers un autre (un payout MOOVCI ne part jamais par Wave). customer_id n'est pas accepté dans le corps.Payout Wave.
network est ignoré. Le pays est country, sinon le seul pays où ce moyen Wave est proposé, sinon le pays du numéro du bénéficiaire. Si son compte Wave est dans un autre pays que son numéro, envoyez country. Numéro dont aucun pays ne se lit, sans country : COUNTRY_UNSUPPORTED (400).Payout par carte ou wallet
Depuis votre application ou votre site, versez vers votre propre carte bancaire ou votre propre wallet par le même POST /v1/payouts.
- Au préalable, enregistrez la carte ou le wallet dans votre tableau de bord, menu Paramètres › Coordonnées de reversement. Sans coordonnées du type demandé, la création est refusée avec
PAYOUT_DESTINATION_MISSING(422). Les mêmes coordonnées servent en Sandbox et en Production. - Obligatoires :
internal_ref,amount,process(le code d'un moyen de catégorieCARDouWALLET, lu dansGET /v1/restriction-options) et l'en-têteIdempotency-Key. - Ignorés :
phone_number,countryetnetwork. Le pays du versement est celui de vos coordonnées de reversement. Unphone_numberenvoyé quand même ne doit pas être vide. - Facultatifs :
currency(par défaut, la devise du pays de vos coordonnées),description,email,firstname,lastnameetwebhook.
Le reste ne change pas : solde réservé, frais et commission, restrictions de votre compte (le pays contrôlé est celui de vos coordonnées), statut, webhooks et annulation, comme pour tout payout.
Payout vers votre carte
| 1 | curl -X POST https://api.urbanpay.net/v1/payouts \ |
| 2 | -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 3 | -H "Idempotency-Key: payout-2026-0008" \ |
| 4 | -H "Content-Type: application/json" \ |
| 5 | -d '{ |
| 6 | "internal_ref": "PAYOUT-2026-0008", |
| 7 | "amount": 25000, |
| 8 | "currency": "XOF", |
| 9 | "process": "CARD" |
| 10 | }' |
Endpoint
POST/v1/payouts
Paramètres
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
internal_ref | string | Oui | Votre référence de versement : une chaîne, ou un entier. 100 caractères au plus, sans retour à la ligne ni caractère de contrôle. Même règle que pour les payins : refusée (DUPLICATE_INTERNAL_REF, 409) tant qu'un payout de cette référence est en attente ou payé, de nouveau libre une fois celui-ci annulé, expiré ou remboursé. |
amount | number | Oui | Montant versé au bénéficiaire, > 0, dans currency. Mêmes règles que pour le payin : pas plus de décimales que la devise, et dans les bornes du moyen de paiement (AMOUNT_INVALID, 400, sinon). |
phone_number | string | Selon le moyen | Téléphone du bénéficiaire, de préférence en format international : obligatoire pour le mobile money et Wave, ignoré pour une carte ou un wallet. Il sert à déduire currency, country et network quand vous ne les envoyez pas. Enregistré et transmis au format international. |
process | string | Oui | Code d'un moyen de paiement mobile money, Wave, carte ou wallet actif (ex. MOBILE, WAVE, CARD), lu dans GET /v1/restriction-options (catégorie MOBILE, WAVE, CARD ou WALLET). Le versement ne part que par ce moyen. |
currency | string (3) | Non | Devise de amount, code ISO 4217. Par défaut : la devise finale du versement (devise du moyen de paiement, sinon du pays, sinon XOF ; Wave : XOF ; carte ou wallet : devise du pays de vos coordonnées de reversement). |
country | string (2) | Non | Code ISO 3166-1 alpha-2. Par défaut : le pays du préfixe du numéro. Ignoré pour une carte ou un wallet. |
network | string | Non | Code de l'opérateur (ex. MTNBJ). Par défaut : reconnu au préfixe du numéro ; aucun opérateur reconnu : PHONE_UNSUPPORTED. Ignoré pour une carte ou un wallet. |
firstname / lastname | string | Non | Nom du bénéficiaire. |
email / description | string | Non | Facultatifs. |
webhook | string | Non | URL de notification de ce payout : elle seule reçoit ses webhooks. Pas de callback ni de cancelback : un payout n'a pas de page de retour. Voir URL de retour. |
| 1 | curl -X POST https://api.urbanpay.net/v1/payouts \ |
| 2 | -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 3 | -H "Idempotency-Key: payout-2026-0007" \ |
| 4 | -H "Content-Type: application/json" \ |
| 5 | -d '{ |
| 6 | "internal_ref": "PAYOUT-2026-0007", |
| 7 | "amount": 25000, |
| 8 | "currency": "XOF", |
| 9 | "process": "MOBILE", |
| 10 | "country": "BJ", |
| 11 | "phone_number": "+2290167000001", |
| 12 | "network": "MTNBJ", |
| 13 | "firstname": "Awa", |
| 14 | "lastname": "Diallo" |
| 15 | }' |
La réponse reprend id_transaction, internal_ref, amount, currency, status, code, message, customer_id et customer_reference. Suivez le statut via GET /v1/payouts/:id.
Montant reçu et frais.
currency). amount_charged et currency_charged (dans GET /v1/payouts/:id et la liste) donnent ce qui a réellement été envoyé. Les frais et la commission sont prélevés sur votre solde : montant + frais + commission y sont réservés dès la création, et rendus si le payout échoue. Aucune exonération ne s'applique à un payout, même pour un compte ou un client exonéré.Important
INSUFFICIENT_BALANCE (422) : le payout n'est pas créé et aucun fournisseur n'est contacté.Annuler un payout
Tant qu'un payout n'est pas encore en cours de traitement chez l'opérateur, vous pouvez l'annuler. Le montant réservé sur votre solde (montant + frais + commission) vous est rendu, et le webhook payout.failed est envoyé.
POST/v1/payouts/:id/cancel
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
reason | string | Non | Motif facultatif, 500 caractères au plus. Il est repris dans le message du payout. |
Code
| 1 | { |
| 2 | "status": "success", |
| 3 | "message": "Payout cancelled", |
| 4 | "data": { |
| 5 | "id_transaction": "7c2e5a10-...", |
| 6 | "internal_ref": "PO-2026-0042", |
| 7 | "amount": 25000, |
| 8 | "currency": "XOF", |
| 9 | "status": "cancelled", |
| 10 | "code": "PAYMENT_CANCEL", |
| 11 | "message": "Cancelled by the merchant. Doublon" |
| 12 | } |
| 13 | } |
| Cas | Code | HTTP |
|---|---|---|
| Payout déjà annulé par vous | Le payout tel quel, sans rien rejouer | 200 |
Payout déjà en cours de traitement chez l'opérateur, ou terminé (validated, cancelled, expired, refunded) | PAYOUT_NOT_CANCELLABLE | 409 |
| Payout inconnu ou d'un autre compte | RESOURCE_NOT_FOUND | 404 |
Note
GET /v1/payouts/:id ou le webhook. En Sandbox, un payout est traité dès sa création : l'annulation y est toujours refusée.Remboursement
Remboursez un Payin validé via POST /v1/refunds. Un remboursement est toujours total : le payeur récupère ce qu'il a payé, frais UrbanPay compris (les frais éventuellement ajoutés par le fournisseur lui-même ne sont pas inclus) : le amount_final du payin, dans sa devise currency_final). Le remboursement partiel n'existe pas, et un payin ne peut être remboursé qu'une fois. L'en-tête Idempotency-Key est obligatoire.
Endpoint
POST/v1/refunds
Paramètres
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
internal_ref | string | Oui | Votre référence de remboursement. Unique pour votre compte, tous environnements et tous statuts confondus : une référence déjà utilisée, même par un remboursement rejeté, ne peut pas resservir. Voir la note ci-dessous. |
payin_id | uuid | Oui | Le id_transaction du payin à rembourser. |
amount | number | Non | Facultatif : le montant est toujours le total payé. S'il est envoyé, il doit valoir exactement le amount_final du payin, sinon REFUND_PARTIAL_NOT_SUPPORTED (409). |
reason | string | Non | Motif facultatif. |
Note
refund.* partent vers l'URL webhook déclarée par le payin remboursé s'il en avait une, sinon vers les URL de votre tableau de bord abonnées à ces événements (voir Webhooks).| 1 | curl -X POST https://api.urbanpay.net/v1/refunds \ |
| 2 | -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 3 | -H "Idempotency-Key: refund-cmd-2026-00123" \ |
| 4 | -H "Content-Type: application/json" \ |
| 5 | -d '{ |
| 6 | "internal_ref": "REF-CMD-2026-00123", |
| 7 | "payin_id": "b3f1c9e2-...", |
| 8 | "reason": "Commande annulée" |
| 9 | }' |
Référence et réessai.
Idempotency-Key : si elle avait abouti, la réponse initiale est rejouée telle quelle, sans créer de second remboursement ; si elle avait échoué, elle est traitée à nouveau. À l'inverse, une nouvelle demande avec une autre clé mais une internal_ref déjà utilisée est refusée avec DUPLICATE_INTERNAL_REF (409), comme pour les payins et les payouts, ou avec REFUND_NOT_ELIGIBLE (409) si la demande vise un payin déjà remboursé ou dont le remboursement est en cours. Générez donc une référence neuve pour chaque demande de remboursement, et gardez la même clé d'idempotence pour ses réessais.Réponse
| Champ | Type | Description |
|---|---|---|
id_refund | string | Identifiant UrbanPay du remboursement. |
internal_ref | string | Votre référence. |
payin_id | string | Le payin remboursé. |
amount | number | Montant remboursé : le amount_final du payin, frais UrbanPay compris (les frais éventuellement ajoutés par le fournisseur lui-même ne sont pas inclus). |
currency | string | Sa devise : le currency_final du payin. |
reason | string | Votre motif, si vous en avez envoyé un. |
status | string | pending, validated ou rejected. |
code | string | Code normalisé (majuscules). Voir Statuts. |
message | string | Le message du code, en anglais. Ne branchez pas votre logique dessus. |
external_ref | string | Référence du remboursement chez le fournisseur, une fois connue. |
created_at / updated_at | string | Dates de création et de dernière mise à jour (ISO 8601). |
validated_at | string | Date de validation du remboursement, c'est-à-dire la date du remboursement du payin (ISO 8601). Absente tant qu'il n'est pas validé. |
Réponse 200
| 1 | { |
| 2 | "status": "success", |
| 3 | "code": "SUCCESS", |
| 4 | "message": "Refund requested", |
| 5 | "data": { |
| 6 | "id_refund": "7c2d0f41-...", |
| 7 | "internal_ref": "REF-CMD-2026-00123", |
| 8 | "payin_id": "b3f1c9e2-...", |
| 9 | "amount": 5075, |
| 10 | "currency": "XOF", |
| 11 | "reason": "Commande annulée", |
| 12 | "status": "pending", |
| 13 | "code": "WAITING_SYSTEM_PAYMENT", |
| 14 | "message": "Waiting for the provider's confirmation.", |
| 15 | "created_at": "2026-09-27T10:15:00.000Z", |
| 16 | "updated_at": "2026-09-27T10:15:00.000Z" |
| 17 | } |
| 18 | } |
Le remboursement passe toujours par le fournisseur qui a encaissé le payin, qui rend au payeur ce qu'il lui avait débité (amount_charged / currency_charged du payin). Il est suivi jusqu'à confirmation et notifié par webhook (payin.refunded). La possibilité de rembourser dépend du moyen de paiement et du fournisseur qui a encaissé : quand celui-ci ne propose pas le remboursement, la demande est refusée.
Refus
| Cas | Code | HTTP |
|---|---|---|
| Payin non validé (en attente, annulé, expiré...), déjà remboursé, ou remboursement déjà en cours | REFUND_NOT_ELIGIBLE | 409 |
amount différent du total payé, y compris votre montant sans les frais | REFUND_PARTIAL_NOT_SUPPORTED | 409 |
| Le fournisseur qui a encaissé le payin ne sait pas rembourser | REFUND_NOT_SUPPORTED | 409 |
| Votre solde ne couvre pas le montant remboursé | INSUFFICIENT_BALANCE | 422 |
Note
Suivre un remboursement
| Route | Rôle |
|---|---|
GET /v1/refunds/:id | Détail d'un remboursement. |
GET /v1/refunds?payin_id=<uuid> | Les remboursements d'un payin, du plus récent au plus ancien. offset (défaut 0) et limit (1 à 200, défaut 25) ; réponse avec pagination: { offset, limit, total }. Payin inconnu : RESOURCE_NOT_FOUND (404). |
POST /v1/refunds/:id/refresh | Re-vérifie le statut auprès du fournisseur, pour un remboursement resté pending (confirmation jamais reçue). Un remboursement déjà terminé est renvoyé tel quel. |
Solde
Votre compte a un solde par environnement : la Sandbox a le sien, alimenté par les payins Sandbox, et il bloque les payouts et remboursements exactement comme en Production. Le solde est tenu en XOF.
| Transaction | Effet sur le solde | Quand |
|---|---|---|
| Payin | + montant net (net_amount : montant moins la commission ; pour un payin exonéré, moins aussi les frais UrbanPay). Les frais propres du fournisseur ne sont jamais déduits. | Dès la validation, disponible tout de suite. |
| Payout | − montant + frais + commission | Réservé à la création ; rendu si le payout échoue. |
| Remboursement | − montant remboursé | Réservé à la demande ; rendu si le remboursement est rejeté. |
Un payout ou un remboursement que le solde ne couvre pas est refusé avec INSUFFICIENT_BALANCE (422) ; le payout n'est alors pas créé.
Consulter le solde
GET/v1/balance
Le solde dans l'environnement de votre clé (Sandbox pour une clé sk_test_…, Production pour sk_live_…).
Réponse 200
| 1 | { |
| 2 | "status": "success", |
| 3 | "code": "SUCCESS", |
| 4 | "message": "OK", |
| 5 | "data": { |
| 6 | "merchant_account_id": "5a0e8c3b-...", |
| 7 | "environment": "prod", |
| 8 | "balance": 125400, |
| 9 | "currency": "XOF", |
| 10 | "updated_at": "2026-09-27T10:15:00.000Z" |
| 11 | } |
| 12 | } |
Mouvements du solde
GET/v1/balance/entries
Les mouvements du solde, du plus récent au plus ancien. Paramètres : offset (≥ 0, défaut 0) et limit (1 à 200, défaut 25).
| Champ | Type | Description |
|---|---|---|
id | string | Identifiant du mouvement. |
kind | string | payin_credit, payin_reversal, payout_reserve, payout_adjust, payout_release, refund_reserve ou refund_release. |
amount | number | Montant signé : positif pour un crédit, négatif pour un débit. |
balance_after | number | Solde après ce mouvement. |
currency | string | XOF. |
payin_id / payout_id / refund_id | string | La transaction à l'origine du mouvement (un seul des trois). |
created_at | string | Date du mouvement (ISO 8601). |
Réponse 200
| 1 | { |
| 2 | "status": "success", |
| 3 | "code": "SUCCESS", |
| 4 | "message": "OK", |
| 5 | "data": [ |
| 6 | { |
| 7 | "id": "e41b7a90-...", |
| 8 | "kind": "payout_reserve", |
| 9 | "amount": -25950, |
| 10 | "balance_after": 125400, |
| 11 | "currency": "XOF", |
| 12 | "payout_id": "9d3c2b1a-...", |
| 13 | "created_at": "2026-09-27T10:15:00.000Z" |
| 14 | } |
| 15 | ], |
| 16 | "pagination": { "offset": 0, "limit": 25, "total": 42 } |
| 17 | } |
Liens de paiement
Un lien de paiement permet d'encaisser sans intégrer l'API de création : vous créez le lien, partagez son URL à votre payeur, et il règle sur une page hébergée par UrbanPay. Chaque paiement abouti génère les mêmes webhooks qu'un Payin classique.
Créer un lien
POST/v1/payment-links
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
title | string | Oui | Intitulé affiché au payeur. |
amount_mode | enum | Oui | FIXED (montant imposé) ou FLEXIBLE (le payeur choisit). |
amount | number | si FIXED | Montant imposé. |
min_amount / max_amount | number | si FLEXIBLE | Bornes du montant libre. |
currency | string (3) | Non | Devise ISO 4217. Par défaut : la devise de votre compte marchand. |
country_code | string (2) | Non | Pays proposé en premier sur la page. Le payeur choisit le sien parmi ceux que votre compte accepte. Par défaut : le pays de votre compte marchand. |
usage_mode | enum | Oui | SINGLE_USE ou REUSABLE. |
max_uses | integer | Non | Plafond d'utilisations (mode réutilisable). |
required_payer_fields | array | Non | Sous-ensemble de firstName, lastName, email. |
expires_at | date | Non | Date d'expiration du lien. |
redirect_type | enum | Non | URL (défaut) ou WHATSAPP. |
redirect_url | string | Non | Où envoyer le payeur une fois son paiement validé sur la page du lien, 500 caractères au plus. Avec URL : une adresse http ou https, ouverte automatiquement après 10 secondes, telle quelle, sans paramètre ajouté. Avec WHATSAPP : votre numéro au format international (ex. +22997000000) ; le payeur ouvre lui-même une conversation pré-remplie avec le montant et la référence du paiement. Aucune redirection après un échec : le payeur peut réessayer sur la page. |
description / customer_id | — | Non | Facultatifs. |
| 1 | curl -X POST https://api.urbanpay.net/v1/payment-links \ |
| 2 | -H "Authorization: Bearer $ACCESS_TOKEN" \ |
| 3 | -H "Content-Type: application/json" \ |
| 4 | -d '{ |
| 5 | "title": "Facture #2026-00123", |
| 6 | "amount_mode": "FIXED", |
| 7 | "amount": 5000, |
| 8 | "currency": "XOF", |
| 9 | "country_code": "BJ", |
| 10 | "usage_mode": "SINGLE_USE" |
| 11 | }' |
La réponse renvoie l'objet lien, dont un slug, son status, son used_count et son environment. Partagez l'URL publique du lien à votre payeur. Elle est construite à partir du slug, par exemple https://app.urbanpay.net/l/<slug>.
Gérer les liens
| Route | Rôle |
|---|---|
GET /v1/payment-links | Lister et filtrer vos liens : usage_mode, search, offset, limit (1 à 200, défaut 25), sort_by (createdAt ou usedCount), sort_dir (asc ou desc). |
GET /v1/payment-links/:id | Détail d'un lien. |
PATCH /v1/payment-links/:id | Modifier un lien (montant, devise et mode d'usage verrouillés dès la première utilisation). active: false désactive le lien sans l'archiver. |
DELETE /v1/payment-links/:id | Archiver un lien. |
Consulter une transaction
Toutes ces routes exigent l'en-tête Authorization: Bearer <jeton>.
| But | Route | Réponse |
|---|---|---|
| Détail d'un payin | GET /v1/payins/:id | L'objet payin. |
| Détail d'un payout | GET /v1/payouts/:id | L'objet payout. |
| Détail d'un remboursement | GET /v1/refunds/:id | L'objet remboursement. |
| Lister les payins | GET /v1/payins | Tableau + pagination. |
| Lister les payouts | GET /v1/payouts | Tableau + pagination. |
| Lister les remboursements d'un payin | GET /v1/refunds?payin_id= | Tableau + pagination. |
| Re-vérifier un payin en attente, expiré ou annulé | POST /v1/payins/:id/refresh | Re-consulte le statut auprès du fournisseur. Sur un payin expiré ou annulé, seul un paiement confirmé est appliqué. Au plus une fois toutes les 2 minutes par payin et une fois par seconde par compte (429 RATE_LIMITED), et jusqu'à 48 h après la création (409 REFRESH_WINDOW_CLOSED). |
| Re-vérifier un payout en attente | POST /v1/payouts/:id/refresh | Idem pour un versement. |
| Re-vérifier un remboursement en attente | POST /v1/refunds/:id/refresh | Idem pour un remboursement. |
Détail d'un payin ou d'un payout
GET /v1/payins/:id et GET /v1/payouts/:id renvoient les champs de la réponse de création (id_transaction, internal_ref, amount, currency, status, code, message, et pour un payin payment_url, expires_at, metadata), complétés par :
| Champ | Type | Description |
|---|---|---|
type | string | payin ou payout. |
external_id / external_ref | string | Identifiant principal et référence de la transaction chez le fournisseur, une fois connus. Pour un payin, ils figurent aussi dans la réponse de création et de rafraîchissement. |
service_id | string | Votre compte marchand. |
customer_id / customer_reference | string | Le client rattaché par X-Customer-Reference et sa référence (customer_reference vaut null sans client). |
amount_original / currency_original | number / string | Votre montant et sa devise, tels qu'envoyés (identiques à amount / currency). |
amount_converted | number | Votre montant converti en currency_final. |
amount_final / currency_final | number / string | Payin : ce que le payeur paie à UrbanPay, frais UrbanPay compris (sur une route où le fournisseur ajoute ses propres frais, le payeur paie en plus agregator_fee). Payout : le montant versé. Exprimé dans la devise de tenue du solde (XOF). |
amount_charged / currency_charged | number / string | Payin : ce que le fournisseur a débité au payeur. Payout : ce qu'il a envoyé au bénéficiaire. Dans la devise finale (moyen de paiement, sinon pays, sinon XOF) ; null tant qu'aucun fournisseur n'a été désigné, et pour une transaction antérieure au 27 septembre 2026. |
payer_amount / payer_currency | number / string | Payin : ce que le payeur a réellement réglé, dans sa propre devise, quand le fournisseur le communique. Il diffère de amount_charged lorsque le fournisseur convertit le prix pour le payeur (17,08 GBP débités, 20,67 EUR réglés) : c'est le montant que connaît sa banque ou son portefeuille. null tant qu'il n'est pas connu. |
fee | number | Frais UrbanPay de la transaction. |
fixed_fee | number | Payin : frais fixe ajouté au montant payé. |
agregator_fee | number | Payin : frais que le fournisseur ajoute lui-même au paiement, à la charge du payeur, indicatif ; jamais déduit de votre crédit. 0 sur une route tarifée par UrbanPay. |
commission | number | Commission UrbanPay. |
net_amount | number | Payin : montant crédité sur votre solde = montant − commission (− frais UrbanPay si payin exonéré). |
fee_bearer | string | Payin : payer, ou merchant pour un payin exonéré (le payeur paie exactement le montant, les frais UrbanPay sont déduits de net_amount). |
debit_amount | number | Payout : montant débité de votre solde (montant + frais + commission). |
process | string | Catégorie du moyen de paiement : MOBILE, CARD, WAVE ou WALLET. |
payment_method | string | Payin : le code du moyen de paiement (celui envoyé dans process), s'il est connu. |
environment | string | dev (Sandbox) ou prod. |
payer_name / payer_phone / payer_email | string | Le payeur (payin) ou le bénéficiaire (payout). |
description | string | Votre libellé. |
payment_link_id | string | Payin : le lien de paiement d'origine, le cas échéant. |
created_at / updated_at | string | Dates de création et de dernière mise à jour (ISO 8601). La mise à jour change à chaque écriture : ce n'est pas une date de validation. |
validated_at | string | Date de la première validation (ISO 8601). Absente tant que la transaction n'est pas validée. |
refunded_at | string | Payin : date du remboursement (ISO 8601). Absente tant qu'il n'est pas remboursé. |
Les champs de tarification (fixed_fee, agregator_fee, commission, net_amount, debit_amount) valent null pour une transaction antérieure au 27 septembre 2026.
Lignes de GET /v1/payins et GET /v1/payouts
Important
id (et non id_transaction) et le code normalisé status_code (et non code). Il n'y a ni amount / currency, ni payment_url : utilisez amount_original / currency_original, et le détail pour le reste.| Champ | Type | Description |
|---|---|---|
id | string | Identifiant UrbanPay : le id_transaction du détail. |
type | string | payin ou payout. |
internal_ref | string | Votre référence. |
external_id | string | Payin : identifiant principal chez le fournisseur, une fois connu. |
external_ref | string | Référence chez le fournisseur, une fois connue. |
service_id / service_name | string | Votre compte marchand et son nom. |
customer_id / customer_reference | string | Le client rattaché et sa référence, comme dans le détail. |
amount_original / currency_original | number / string | Votre montant et sa devise. |
amount_final | number | Payin : payé par le payeur à UrbanPay, frais UrbanPay compris. Payout : montant versé. En XOF. |
amount_charged / currency_charged | number / string | Débité au payeur (payin) ou envoyé au bénéficiaire (payout), dans la devise finale ; null si inconnu. |
fee | number | Frais UrbanPay. |
fixed_fee / agregator_fee / net_amount / fee_bearer | number / string | Payin uniquement, comme dans le détail. |
commission | number | Commission UrbanPay. |
debit_amount | number | Payout uniquement : débité de votre solde. |
status | string | Statut de cycle de vie (minuscules). |
status_code | string | Code normalisé (majuscules) : le code du détail. |
message | string | Le message du code, en anglais, comme dans le détail. Voir Statuts. |
process | string | Catégorie : MOBILE, CARD, WAVE ou WALLET. |
payment_method | string | Payin : code du moyen de paiement, s'il est connu. |
country_code / operator_code | string | Pays et opérateur (absents du détail). |
agregator_code | string | Fournisseur de paiement retenu (absent du détail). |
environment | string | dev ou prod. |
payer_name / payer_phone | string | Payeur (payin) ou bénéficiaire (payout). |
description | string | Votre libellé. |
created_at / updated_at | string | Dates (ISO 8601). |
validated_at | string | Date de la première validation, absente tant que la transaction n'est pas validée. |
expires_at / refunded_at / metadata | string / object | Payin uniquement (refunded_at : date du remboursement, absente tant qu'il n'est pas remboursé). |
GET /v1/payins?status=VALIDATED&limit=1 (200)
| 1 | { |
| 2 | "status": "success", |
| 3 | "code": "SUCCESS", |
| 4 | "message": "OK", |
| 5 | "data": [ |
| 6 | { |
| 7 | "id": "b3f1c9e2-...", |
| 8 | "type": "payin", |
| 9 | "internal_ref": "CMD-2026-00123", |
| 10 | "amount_original": 5000, |
| 11 | "currency_original": "XOF", |
| 12 | "amount_final": 5075, |
| 13 | "amount_charged": 5075, |
| 14 | "currency_charged": "XOF", |
| 15 | "status": "validated", |
| 16 | "status_code": "SUCCESS", |
| 17 | "message": "Transaction successful.", |
| 18 | "fee_bearer": "payer", |
| 19 | "process": "MOBILE", |
| 20 | "payment_method": "MOBILE", |
| 21 | "country_code": "BJ", |
| 22 | "environment": "prod", |
| 23 | "created_at": "2026-09-27T10:02:11.000Z", |
| 24 | "updated_at": "2026-09-27T10:03:40.000Z" |
| 25 | } |
| 26 | ], |
| 27 | "pagination": { "offset": 0, "limit": 1, "total": 128, "next_cursor": "djE6…" } |
| 28 | } |
Ligne abrégée dans l'exemple. pagination.total est le nombre total de résultats pour ces filtres ; au-delà de 1 000, il peut retarder de quelques secondes. pagination.next_cursor (null sur la dernière page) se repasse en cursor : c'est la méthode recommandée pour parcourir une longue liste.
Paramètres de liste
Les listes acceptent des filtres combinables et une pagination par curseur ou par décalage :
| Paramètre | Type | Notes |
|---|---|---|
status | enum | PENDING, VALIDATED, CANCELLED, EXPIRED, REFUNDED. |
process | enum | Catégorie : MOBILE, CARD, WAVE, WALLET. |
payment_method | string | Code exact d'un moyen de paiement (celui envoyé dans process à la création, ex. ORANGECM), là où process filtre par catégorie. |
customer_id | uuid | Les transactions d'un de vos clients. |
country | string (2) | Code pays. |
operator | string | Code opérateur. |
agregator | string | Code du fournisseur de paiement. |
payment_link_id | uuid | Payins uniquement : ceux d'un lien de paiement. |
internal_ref | string | Votre référence (égalité exacte). |
search | string | Recherche texte libre. |
created_from / created_to | date | Bornes de période, sur la date de création. |
validated_from / validated_to | date | Bornes de période, sur la date de validation : les transactions validées dans la période. |
cursor | string | Le pagination.next_cursor de la page précédente ; mêmes filtres et même tri. Incompatible avec un offset non nul (400). |
offset | int | ≥ 0, défaut 0, au plus 100 000 (au-delà : utilisez cursor). |
limit | int | 1 à 200, défaut 25. |
sort_by | enum | createdAt ou amountFinal. |
sort_dir | enum | asc ou desc. |
Note
/refresh et les GET sont la source de vérité pour réconcilier une transaction si vous n'avez pas reçu (ou pas traité) le webhook.Statuts
Chaque transaction porte deux champs complémentaires : status (le cycle de vie) et code (le détail normalisé). Fondez votre logique métier sur status en priorité.
status : cycle de vie (minuscules)
| Valeur | Terminal | Signification |
|---|---|---|
pending | non | Créée ; en attente d'action du payeur ou de confirmation du fournisseur. |
validated | oui | Paiement réussi / réglé (état de succès). |
cancelled | oui | Annulée, refusée ou échouée. |
expired | oui | Expirée avant achèvement. |
refunded | oui | Payin remboursé. |
Les remboursements utilisent leur propre jeu : pending, validated, rejected.
Important
validated, mais l'événement webhook correspondant s'appelle payin.succeeded (voir Webhooks).code : code normalisé (majuscules)
Le code décrit plus finement l'issue, dans un vocabulaire stable indépendant du moyen de paiement, et le message est toujours celui de ce code, en anglais. Branchez votre logique sur status, puis au besoin sur code, jamais sur message : sa formulation peut évoluer. Traitez code comme un ensemble ouvert (un nouveau code peut apparaître). Voici la liste complète :
| status | code | message | Sens |
|---|---|---|---|
pending | WAITING_CUSTOMER_PAYMENT | Waiting for the customer's confirmation. | Le payeur doit valider le paiement, par exemple sur son téléphone. |
pending | WAITING_SYSTEM_PAYMENT | Waiting for the provider's confirmation. | En attente de la confirmation du fournisseur. |
pending | PAYMENT_REDIRECTED | Waiting for the payer on the payment page. | Le payeur doit terminer sur la page de paiement (payment_url). |
pending | WAITING_PAYER_INPUT | Waiting for the payer on the hosted checkout page. | Page hébergée : le payeur n'a pas encore choisi son moyen de paiement. |
pending | WAITING_PAYER_CODE | Waiting for the payer's authorization code. | Le payeur doit saisir un code d'autorisation (Orange Burkina, par exemple). |
validated | SUCCESS | Transaction successful. | Succès. |
cancelled | PAYMENT_CANCEL | Transaction cancelled or declined. | Annulé ou refusé par le payeur ; payout annulé (le vôtre garde votre motif dans le message). |
cancelled | TRANSACTION_CANCEL | Transaction failed or refused by the provider. | Échec ou refus chez le fournisseur. |
cancelled | INSUFFICIENT_BALANCE | Insufficient balance. | Solde insuffisant. |
cancelled | PAYER_LIMIT_REACHED | The payer's limit has been reached. | Plafond du payeur atteint. |
cancelled | LOW_BALANCE_OR_PAYEE_LIMIT_REACHED_OR_NOT_ALLOWED | Refused: insufficient balance, beneficiary's limit reached, or transaction not allowed. | Solde ou plafond atteint côté bénéficiaire, ou opération non autorisée. |
cancelled | PAYER_NOT_VALID | The payer's account is invalid or inactive. | Compte du payeur introuvable ou inactif. |
cancelled | PAYEE_NOT_VALID | The beneficiary's account is invalid or cannot receive this transaction. | Compte du bénéficiaire introuvable ou qui ne peut pas recevoir (payout). |
cancelled | PHONE_UNSUPPORTED | The provider does not serve this phone number's operator. | Le fournisseur ne sert pas l'opérateur de ce numéro. |
cancelled | PAYMENT_MODE_NOT_SUPPORTED | This payment method is not supported for this transaction. | Moyen de paiement non supporté pour ce pays ou cet opérateur. |
cancelled | ROUTING_FAILED | No payment provider could process this transaction. | Aucun fournisseur n'a pu traiter la transaction après sa création. |
cancelled | INTERNAL_PROCESSING_ERROR | The provider could not process this transaction. | Échec technique chez le fournisseur. La transaction est close : créez-en une nouvelle (son internal_ref est de nouveau libre). |
expired | TRANSACTION_EXPIRED | Transaction expired. | Expirée avant achèvement. |
refunded | REFUNDED | Transaction refunded. | Payin remboursé. |
Un code propre à un fournisseur ne vous parvient jamais : une transaction dont l'issue n'a pas de code dans ce tableau porte le code générique de son statut, WAITING_SYSTEM_PAYMENT, SUCCESS, TRANSACTION_CANCEL, TRANSACTION_EXPIRED ou REFUNDED. Un remboursement est pending / WAITING_SYSTEM_PAYMENT, puis validated / REFUNDED ou rejected avec un code d'échec du tableau.
Idempotence
Les créations (POST /v1/payins, /v1/payouts, /v1/refunds) exigent un en-tête Idempotency-Key. Il garantit qu'un réessai après une coupure réseau ne crée jamais de doublon : réutilisez la même clé pour un même envoi logique.
En-tête
| 1 | Idempotency-Key: <votre-clé-unique-par-transaction> |
| Situation | Résultat |
|---|---|
| Même clé, même contenu, réponse déjà produite | La réponse initiale est rejouée à l'identique (même statut, même corps). Le traitement n'est pas ré-exécuté. |
| Même clé, contenu différent | IDEMPOTENCY_KEY_CONFLICT (409). |
| Même clé, requête initiale encore en cours | IDEMPOTENCY_KEY_IN_PROGRESS (409). Réessayez dans un instant. |
| Même clé, la requête initiale avait échoué (erreur) | La requête est traitée à nouveau. |
| Clé absente ou vide | VALIDATION_ERROR (400). |
Note
Note
internal_ref : voir DUPLICATE_INTERNAL_REF. Pour les remboursements, l'internal_ref est unique pour votre compte et ne peut jamais resservir (voir Remboursement).URL de retour et d'annulation
Quand un payin passe par une page (page de paiement UrbanPay, page de carte ou de wallet du fournisseur), UrbanPay renvoie ensuite le payeur vers votre site : vers votre URL de retour si le paiement a abouti, vers votre URL d'annulation sinon. Ces redirections servent à afficher le bon écran au payeur. L'issue du paiement, elle, vous parvient par webhook.
Les URL du compte
Elles se déclarent dans votre tableau de bord, menu Intégration › Webhooks, carte « Redirections client ». Les URL de retour et d'annulation valent pour tous vos payins, en Sandbox comme en Production.
| URL | Champ de la requête | Rôle |
|---|---|---|
| URL de retour (paiement réussi) | callback | Où le payeur est renvoyé quand le paiement est validé, ou quand sa confirmation est encore attendue (avec status=pending). |
| URL d'annulation | cancelback | Où le payeur est renvoyé quand le paiement est annulé, refusé ou expiré. Sans elle, l'URL de retour sert aussi dans ces cas. |
| URL de notification | webhook | Où UrbanPay envoie les webhooks, une par environnement : voir Webhooks. |
Note
URL déclarées dans la requête
Une transaction peut porter ses propres URL, à la place de celles du compte et pour elle seule. Il suffit de les envoyer dans la requête, sans réglage à demander. Une transaction qui n'en envoie pas utilise les URL du compte.
| Route | Champs d'URL acceptés |
|---|---|
POST /v1/payins | callback, cancelback, webhook. |
POST /v1/payouts | webhook seulement : un payout n'a pas de page de retour. |
POST /v1/refunds | Aucun : les webhooks d'un remboursement suivent ceux du payin remboursé. |
POST /v1/payment-links | redirect_type et redirect_url : voir Liens de paiement. |
Payin avec ses propres URL
| 1 | { |
| 2 | "internal_ref": "CMD-2026-00123", |
| 3 | "amount": 5000, |
| 4 | "process": "MOBILE", |
| 5 | "phone_number": "+2290167000001", |
| 6 | "callback": "https://boutique.example/commande/merci", |
| 7 | "cancelback": "https://boutique.example/commande/echec", |
| 8 | "webhook": "https://boutique.example/api/urbanpay/webhook" |
| 9 | } |
| Règle | Détail |
|---|---|
| Format | URL complète, 2 000 caractères au plus, en https en Production (http accepté en Sandbox), vers une adresse publique (pas d'adresse IP privée ou locale). Sinon : CALLBACK_INVALID (pour callback et cancelback) ou WEBHOOK_INVALID, 400, et rien n'est créé. |
| Priorité | L'URL de la requête passe avant celle du compte, pour cette transaction seulement. Un champ absent reprend l'URL du compte. |
webhook | Remplace toutes vos URL de notification pour cette transaction et ses remboursements : elle seule reçoit tous leurs événements, y compris payout.pending et refund.*, signés avec le secret de signature de votre compte pour l'environnement. |
| Noms des champs | Seuls callback, cancelback et webhook sont lus. callback_url, cancelback_url ou webhook_url sont ignorés, sans erreur, comme tout champ inconnu. |
Quand le payeur est renvoyé
| Situation | Ce que voit le payeur |
|---|---|
| Retour depuis la page du fournisseur (carte, wallet, page de paiement mobile money) | Une page UrbanPay relit l'issue quelques secondes (8 au plus), puis l'envoie aussitôt vers votre URL de retour si le paiement est validé, vers votre URL d'annulation s'il est annulé ou expiré, et vers votre URL de retour avec status=pending si la confirmation n'est pas encore arrivée. |
Page de paiement UrbanPay (checkout_mode: "hosted", ou page qui demande un code au payeur) | Paiement validé : écran de résultat, puis redirection automatique vers votre URL de retour au bout de 10 secondes ; le payeur peut partir plus tôt par le bouton. Échec : un bouton mène à votre URL d'annulation, sans redirection automatique. |
| Mobile money validé sur le téléphone, sans page | Aucune redirection : le payeur confirme sur son téléphone. Suivez l'issue par webhook ou GET /v1/payins/:id. |
| Payout, remboursement | Jamais : aucun payeur ne passe par une page. Webhook seulement. |
| Lien de paiement | Le redirect_url du lien, une fois le paiement validé. |
Données ajoutées à l'URL
La redirection est une simple navigation du payeur (GET) : rien n'est envoyé en POST à ces URL. UrbanPay ajoute trois paramètres à votre URL de retour ou d'annulation, en conservant ceux qu'elle contient déjà :
| Paramètre | Valeur |
|---|---|
id_transaction | Le id_transaction du payin. |
internal_ref | Votre référence de commande. |
status | Le statut du payin au moment de la redirection, en minuscules : validated, cancelled, expired ou pending. Voir Statuts. |
Exemple
| 1 | URL déclarée : https://boutique.example/commande/merci?panier=42 |
| 2 | Redirection : https://boutique.example/commande/merci?panier=42&id_transaction=b3f1c9e2-...&internal_ref=CMD-2026-00123&status=validated |
Important
GET /v1/payins/:id avec votre jeton. status=pending signifie seulement que la confirmation n'est pas encore arrivée : affichez « paiement en cours de confirmation » et attendez le webhook. Prévoyez aussi que ces paramètres manquent, par exemple si le payeur ouvre l'adresse directement.Les données complètes de la transaction (montants, frais, devise débitée, moyen de paiement, vos metadata) arrivent par webhook : voir Corps de l'événement.
Webhooks
UrbanPay notifie votre serveur à chaque changement de statut, via une requête POST signée cryptographiquement vers l'URL configurée sur votre compte.
Configuration de l'URL
Les URL de notification se déclarent dans votre tableau de bord, séparément pour la Sandbox et la Production : l'URL historique du compte, et jusqu'à 5 URL par environnement abonnées chacune aux événements de votre choix. Le champ webhook d'un payin ou d'un payout remplace toutes ces URL pour cette transaction (et ses remboursements), voir URL déclarées dans la requête. Les événements Sandbox sont toujours envoyés à l'URL Sandbox, et les événements Production à l'URL Production.
Événements disponibles
| Événement | Déclenchement |
|---|---|
payin.pending | Payin créé, en attente. |
payin.succeeded | Payin validé. |
payin.failed | Payin annulé ou expiré. |
payin.refunded | Payin remboursé (envoyé une fois le remboursement confirmé, en plus de refund.succeeded). |
payout.pending | Payout accepté par le fournisseur, résultat attendu plus tard. Envoyé une seule fois, à la création ; absent si le payout est réglé ou refusé d'emblée. |
payout.succeeded | Payout validé. |
payout.failed | Payout échoué. |
payout.refunded | Payout contre-passé / remboursé. |
refund.pending | Remboursement créé, en attente du fournisseur. |
refund.succeeded | Remboursement confirmé par le fournisseur (statut validated). |
refund.failed | Remboursement rejeté (statut rejected) : refus du fournisseur, ou envoi impossible après plusieurs tentatives. |
Note
payout.pending et les trois événements refund.* ne sont envoyés qu'aux URL de webhook que vous déclarez dans votre tableau de bord (jusqu'à 5 par environnement, chacune abonnée aux événements de votre choix et signée avec son propre secret) et qui s'y sont abonnées. L'URL de notification historique de votre compte reçoit uniquement les sept autres événements, pour ne pas perturber une intégration existante.Corps de l'événement
Le corps est du JSON en camelCase (à la différence de l'API REST) :
payin.succeeded
| 1 | { |
| 2 | "event": "payin.succeeded", |
| 3 | "idTransaction": "b3f1c9e2-...", |
| 4 | "internalRef": "CMD-2026-00123", |
| 5 | "externalId": "TX-4410927", |
| 6 | "externalRef": "FX-88213", |
| 7 | "amount": 5000, |
| 8 | "currency": "XOF", |
| 9 | "amountPaid": 5075, |
| 10 | "currencyPaid": "XOF", |
| 11 | "amountCharged": 5075, |
| 12 | "currencyCharged": "XOF", |
| 13 | "status": "validated", |
| 14 | "code": "SUCCESS", |
| 15 | "process": "MOBILE", |
| 16 | "paymentMethod": "MOBILE", |
| 17 | "country": "BJ", |
| 18 | "message": "Transaction successful.", |
| 19 | "metadata": { "order_id": "4521" }, |
| 20 | "createdAt": "2026-09-27T10:00:00.000Z", |
| 21 | "validatedAt": "2026-09-27T10:01:12.000Z" |
| 22 | } |
metadata n'apparaît que dans les webhooks payin.*, et seulement si vous en avez envoyé un à la création du payin : c'est exactement ce que vous avez envoyé.
| Champ | Sens |
|---|---|
idTransaction / internalRef | L'identifiant UrbanPay de la transaction (le id_transaction de l'API) et votre référence. |
externalId / externalRef | L'identifiant principal de la transaction chez le fournisseur et sa référence : ce sont les external_id / external_ref de l'API. externalId n'est envoyé que pour un payin, une fois connu ; externalRef le reprend quand le fournisseur ne donne pas de seconde référence. |
amount / currency | Votre montant et sa devise, tels qu'envoyés à la création. |
amountPaid / currencyPaid | Le amount_final / currency_final de la transaction : payin, ce que paie le payeur, frais compris ; payout, le montant versé. |
amountCharged / currencyCharged | Ce que le fournisseur a réellement débité au payeur (payin) ou envoyé au bénéficiaire (payout), dans la devise finale : ce sont les amount_charged / currency_charged de l'API. Absents tant qu'ils ne sont pas connus. |
payerAmount / payerCurrency | Payin : ce que le payeur a réellement réglé, dans sa propre devise, quand le fournisseur le communique (par exemple 20,67 EUR pour 17,08 GBP débités) : ce sont les payer_amount / payer_currency de l'API. Absents tant qu'ils ne sont pas connus, et des événements payout.*. |
process / paymentMethod | Catégorie du moyen de paiement, et son code (le payment_method de l'API). paymentMethod est absent quand il n'est pas connu, et des événements payout.*. |
createdAt / validatedAt / refundedAt | Dates de création, de première validation et (payin) de remboursement, en ISO 8601 : les created_at / validated_at / refunded_at de l'API. validatedAt et refundedAt sont absents tant que l'étape n'a pas eu lieu. |
Les événements payout.* ont la même forme, sans metadata, externalId ni paymentMethod. amountPaid y est le montant versé au bénéficiaire :
payout.succeeded
| 1 | { |
| 2 | "event": "payout.succeeded", |
| 3 | "idTransaction": "5a9e7d10-...", |
| 4 | "internalRef": "VERS-2026-00045", |
| 5 | "externalRef": "FX-90117", |
| 6 | "amount": 10000, |
| 7 | "currency": "XOF", |
| 8 | "amountPaid": 10000, |
| 9 | "currencyPaid": "XOF", |
| 10 | "amountCharged": 10000, |
| 11 | "currencyCharged": "XOF", |
| 12 | "status": "validated", |
| 13 | "code": "SUCCESS", |
| 14 | "process": "MOBILE", |
| 15 | "country": "BJ", |
| 16 | "message": "Transaction successful." |
| 17 | } |
Les événements refund.* décrivent le remboursement :
refund.succeeded
| 1 | { |
| 2 | "event": "refund.succeeded", |
| 3 | "idRefund": "7c2d0f41-...", |
| 4 | "idTransaction": "b3f1c9e2-...", |
| 5 | "internalRef": "REF-CMD-2026-00123", |
| 6 | "amount": 5075, |
| 7 | "currency": "XOF", |
| 8 | "status": "validated", |
| 9 | "code": "REFUNDED", |
| 10 | "message": "Transaction refunded.", |
| 11 | "reason": "Commande annulée", |
| 12 | "createdAt": "2026-09-27T10:15:00.000Z", |
| 13 | "validatedAt": "2026-09-27T10:16:40.000Z" |
| 14 | } |
| Champ | Sens |
|---|---|
idRefund | Le id_refund du remboursement. |
idTransaction | Le id_transaction du payin remboursé. |
internalRef | La référence du remboursement (pas celle du payin). |
amount / currency | Montant remboursé et sa devise : le amount_final / currency_final du payin. |
status | pending, validated ou rejected. |
code / message | Code normalisé et son message, en anglais. Voir Statuts. |
reason | Votre motif (chaîne vide si vous n'en avez pas envoyé). |
createdAt / validatedAt | Date de la demande, et date de validation du remboursement une fois validé (ISO 8601) : le validated_at de l'API. |
Signature
Chaque requête webhook porte ces en-têtes :
| En-tête | Valeur |
|---|---|
X-UrbanPay-Timestamp | Horodatage Unix (secondes) de l'envoi. |
X-UrbanPay-Signature | sha256=<hex> |
X-UrbanPay-Event-Id | Identifiant de l'événement, identique d'une tentative à l'autre : servez-vous-en pour dédoublonner. |
X-UrbanPay-Attempt | Numéro de la tentative (1 pour la première). |
La signature est un HMAC-SHA256 calculé ainsi :
Construction de la signature
| 1 | signature = "sha256=" + HMAC_SHA256( |
| 2 | clé = <votre secret de signature webhook>, |
| 3 | message = timestamp + "." + corps_brut |
| 4 | ).hex() |
où timestamp est la valeur de X-UrbanPay-Timestamp et corps_brut est le corps de la requête tel que reçu (les octets bruts, avant toute re-sérialisation). Le secret est celui de l'environnement concerné (Sandbox ou Production), disponible dans votre tableau de bord.
Vérification côté marchand
Node.js / Express
| 1 | import crypto from "node:crypto" |
| 2 | |
| 3 | // Important : lire le corps BRUT, pas un JSON déjà re-sérialisé |
| 4 | app.post("/webhooks/urbanpay", |
| 5 | express.raw({ type: "application/json" }), |
| 6 | (req, res) => { |
| 7 | const timestamp = req.header("X-UrbanPay-Timestamp") |
| 8 | const received = req.header("X-UrbanPay-Signature") // "sha256=..." |
| 9 | const rawBody = req.body.toString("utf8") |
| 10 | |
| 11 | // 1. Fraîcheur : rejeter un événement trop ancien (anti-rejeu) |
| 12 | if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { |
| 13 | return res.sendStatus(400) |
| 14 | } |
| 15 | |
| 16 | // 2. Recalcul de la signature |
| 17 | const expected = "sha256=" + crypto |
| 18 | .createHmac("sha256", process.env.URBANPAY_WEBHOOK_SECRET) |
| 19 | .update(timestamp + "." + rawBody) |
| 20 | .digest("hex") |
| 21 | |
| 22 | // 3. Comparaison à temps constant |
| 23 | const ok = received && received.length === expected.length && |
| 24 | crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) |
| 25 | if (!ok) return res.sendStatus(401) |
| 26 | |
| 27 | const event = JSON.parse(rawBody) |
| 28 | // ... traiter event.event, event.idTransaction, event.status ... |
| 29 | |
| 30 | return res.sendStatus(200) |
| 31 | } |
| 32 | ) |
Réessais et doublons
Répondez 2xx rapidement (dans les 5 secondes) et traitez l'événement de façon idempotente : un même changement de statut peut vous parvenir plus d'une fois (par exemple si vous accusez réception tardivement, ou si un statut est reconfirmé) ; dédoublonnez avec X-UrbanPay-Event-Id. Considérez les webhooks comme des notifications best-effort : si vous en manquez un, réconciliez toujours via GET /v1/payins/:id ou POST /v1/payins/:id/refresh, qui font autorité.
En cas d'échec (délai dépassé, erreur réseau, réponse autre que 2xx ; une redirection 3xx n'est pas suivie et compte comme un échec), UrbanPay réessaie : en Production 8 tentatives au total sur environ 45 h (10 s, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h), en Sandbox 4 tentatives sur environ 13 min (30 s, 2 min, 10 min). L'ordre entre deux événements d'une même transaction n'est pas garanti.
Important
Erreurs
Une erreur renvoie l'enveloppe { "status": "error", "code", "message", "details?" } avec un statut HTTP cohérent. Le code est stable et indépendant du moyen de paiement : votre intégration ne dépend jamais d'un code propre à un prestataire. Le message, en anglais, explique la cause au développeur ; ne branchez pas votre logique dessus, sa formulation peut évoluer. Le tableau details précise, le cas échéant, les erreurs de champ.
Note
pending revient en HTTP 200 avec une enveloppe success.| Code | HTTP | Sens |
|---|---|---|
VALIDATION_ERROR | 400 | Corps/paramètres invalides (ou Idempotency-Key manquant), dont un process absent ou qui n'est pas le code d'un moyen de paiement actif (création, ou filtre de GET /v1/countries), un champ que le payeur choisit envoyé en mode hébergé (process, country, network), une devise absente quand votre compte marchand n'en a pas, un process de payout absent ou qui n'est pas le code d'un moyen mobile money, Wave, carte ou wallet actif. |
EXPIRED_UNSUPPORTED | 400 | expire_time inférieur à 1 seconde, ou au-delà de 43 200 secondes (12 h). |
CALLBACK_INVALID / WEBHOOK_INVALID | 400 | URL de retour ou de notification invalide. |
PHONE_REQUIRED | 400 | phone_number manquant pour un moyen de paiement mobile money ou Wave. |
EMAIL_REQUIRED | 400 | email manquant pour une carte ou un wallet. |
AMOUNT_INVALID | 400 | Montant avec plus de décimales que sa devise, ou qu'aucun fournisseur n'accepte pour ce moyen de paiement (bornes minimum / maximum). |
COUNTRY_UNSUPPORTED | 400 | Pays non activé, pays que votre compte n'accepte pas (page hébergée, lien de paiement), moyen de paiement proposé dans plusieurs pays sans country ni numéro qui donne le pays, aucun pays disponible pour votre compte, ou country inconnu dans GET /v1/countries. |
CURRENCY_INVALID | 400 | Devise inconnue ou inactive, ou aucune devise finale utilisable pour ce paiement (ni celle du moyen de paiement, ni celle du pays, ni XOF). |
PHONE_ERROR | 400 | Aucun pays ne peut être lu dans le numéro (GET /v1/countries?phone=), ou numéro invalide (7 à 15 chiffres) à la création. |
PHONE_UNSUPPORTED | 400 | Aucun opérateur reconnu au préfixe du numéro (envoyez network), network qui n'est le code d'aucun opérateur ou d'un opérateur d'un autre pays, ou pays du numéro absent du catalogue, désactivé ou archivé (création, ou GET /v1/countries?phone=). |
HEADER_INVALID | 401 | Clé d'API invalide, ou en-tête Authorization manquant. |
TOKEN_EXPIRED | 401 | Jeton expiré. Demandez un nouveau jeton avec votre clé privée. |
TOKEN_INVALID | 401 | Jeton invalide ou mal formé. |
RESOURCE_FORBIDDEN | 403 | Accès non autorisé à cette ressource, KYC du compte non validé en Production, ou moyen, pays ou opérateur non autorisé pour votre compte. |
PAYOUT_NOT_ENABLED | 403 | Le payout n'est pas activé pour votre compte marchand : demandez-le à UrbanPay. |
IP_NOT_ALLOWED | 403 | Si vous avez restreint vos clés à une liste d'adresses IP (tableau de bord), un appel depuis une autre adresse est refusé. |
RESOURCE_NOT_FOUND | 404 | Identifiant inconnu. |
CUSTOMER_NOT_FOUND | 404 | X-Customer-Reference ne désigne aucun de vos clients. |
IDEMPOTENCY_KEY_CONFLICT | 409 | Même clé, contenu différent. |
IDEMPOTENCY_KEY_IN_PROGRESS | 409 | Requête initiale encore en cours. |
DUPLICATE_INTERNAL_REF | 409 | Un payin (ou un payout) de cette internal_ref est déjà en attente ou payé ; pour un remboursement, la référence est déjà utilisée par un autre remboursement du compte. |
REFUND_NOT_ELIGIBLE | 409 | Payin non remboursable : non validé, déjà remboursé, ou remboursement déjà en cours. |
REFUND_PARTIAL_NOT_SUPPORTED | 409 | Montant de remboursement différent du total payé : le remboursement est toujours total. |
REFUND_NOT_SUPPORTED | 409 | Le fournisseur qui a encaissé le payin ne sait pas rembourser. |
REFRESH_WINDOW_CLOSED | 409 | Payin créé il y a plus de 48 h : son statut ne peut plus être re-vérifié auprès du fournisseur. Contactez le support UrbanPay. |
PAYOUT_NOT_CANCELLABLE | 409 | Payout déjà en cours de traitement chez le fournisseur, ou terminé : il ne peut plus être annulé. |
INSUFFICIENT_BALANCE | 422 | Votre solde ne couvre pas le payout ou le remboursement demandé. |
PAYOUT_DESTINATION_MISSING | 422 | Payout par carte ou wallet sans carte ou wallet enregistré dans votre tableau de bord (Paramètres › Coordonnées de reversement). |
CUSTOMER_INACTIVE | 422 | X-Customer-Reference désigne un client désactivé. |
ROUTING_FAILED | 422 | Aucun fournisseur de paiement éligible pour cette transaction (moyen, pays, opérateur, montant). Ne réessayez pas à l'identique : changez de moyen de paiement, de pays ou de montant. |
RATE_LIMITED | 429 | Trop de requêtes : réessayez après le délai indiqué par l'en-tête Retry-After. |
SERVICE_OVERLOADED | 429 | Service momentanément surchargé : réessayez après le délai indiqué par l'en-tête Retry-After, avec la même clé d'idempotence. |
HTTP_400 / HTTP_404… | 400, 404… | Requête refusée avant d'atteindre l'API : corps JSON illisible (HTTP_400), route inconnue (HTTP_404). Le code reprend le statut HTTP. |
INTERNAL_ERROR | 500 | Erreur serveur inattendue. |
Bonnes pratiques d'intégration
- Toujours envoyer un
Idempotency-Keyunique par transaction, et le réutiliser à l'identique lors d'un réessai. - Stocker
id_transactiondès la création : c'est votre clé pour consulter, rafraîchir ou rembourser. - Vérifier la signature de chaque webhook et traiter les événements de façon idempotente (un doublon est possible).
- Réconcilier via
GET/refreshplutôt que de dépendre uniquement des webhooks. - Brancher la logique sur
status(fermé) et traitercodecomme un ensemble ouvert. - Gérer les erreurs transitoires : réessayez les
429(RATE_LIMITEDetSERVICE_OVERLOADED, en respectant l'en-têteRetry-After) et les erreurs5xxou les réponses qui ne sont pas du JSON (page d'erreur d'un intermédiaire réseau) avec un réessai à intervalle croissant, en conservant la même clé d'idempotence. Un422ROUTING_FAILEDne se réessaie pas à l'identique. - Parcourir les listes avec
cursorplutôt qu'avecoffset. - Développer d'abord en Sandbox, puis basculer en Production en changeant seulement la paire de clés.
- Protéger vos secrets (clé privée, secret de signature) : ils ne transitent jamais côté navigateur.
Sandbox
La Sandbox reproduit le comportement de l'API de Production, sans mouvement d'argent réel. Elle est déterministe : des numéros de test vous permettent de valider chaque branche de votre intégration (succès, refus, attente, expiration, fournisseur indisponible) avant de passer en Production.
Pour l'utiliser, présentez votre clé secrète Sandbox (sk_test_…) à POST /v1/auth/token. Aucun changement de code ni d'URL n'est nécessaire entre Sandbox et Production.
Numéros de test
| Numéro | Issue | status / code |
|---|---|---|
+2290167000001 | Succès. | validated / SUCCESS |
+2290167000002 | Échec. | cancelled / PAYMENT_CANCEL |
+2290167000003 | Reste en attente. | pending |
+2290167000004 | Expiration. | expired / TRANSACTION_EXPIRED |
+2290167000005 | Fournisseur indisponible. | cancelled / INTERNAL_PROCESSING_ERROR |
Tout autre numéro aboutit à un succès. L'issue arrive quelques secondes après la création, par le même chemin qu'en Production (webhook compris). Envoyez network (ex. MTNBJ) avec un numéro de test.
| Sujet | Comportement en Sandbox |
|---|---|
| Remboursement | Toujours abouti. |
| Payout | Refusé (PAYMENT_MODE_NOT_SUPPORTED) pour les fournisseurs qui ne proposent pas le décaissement. |
| Webhooks | 4 tentatives au total sur environ 13 min (réessais après 30 s, 2 min, 10 min). |
| Conservation | Les données Sandbox (transactions terminées, liens de paiement qui ne peuvent plus être payés) sont supprimées après 10 jours. |
SDK
Des bibliothèques clientes officielles sont en préparation. En attendant, l'API REST versionnée (préfixe /v1, enveloppe JSON systématique) reste le contrat de référence. Elle s'utilise avec n'importe quel client HTTP standard.