Contrat API — Portefeuille de services & tickets rémunérés
Version 1 — 10/08/2026. Base : https://live.jmoai.net
Toutes les routes exigent l’en-tête Authorization: Bearer <jeton>.
Montants en XOF, entiers ou décimaux à 2 chiffres. Toutes les réponses suivent la forme
{ success: boolean, data?: …, error?: { code, message } }.
Le principe en une phrase
Le client recharge un portefeuille, puis dépense ce solde pour poster des missions rémunérées. L’argent d’une mission est bloqué en séquestre dès la publication : l’expert qui la prend sait que la somme est là, et le client ne peut pas la dépenser ailleurs entre-temps.
recharger ──► solde disponible ──► poster une mission ──► séquestre
├─ mission terminée ─► expert payé
└─ mission annulée ─► client remboursé
1. Consulter le portefeuille
GET /api/v1/support/wallet/me?limit=20
{
"success": true,
"data": {
"balance": 12500.0,
"currency": "XOF",
"status": "ACTIVE",
"entries": [
{
"id": "…",
"direction": "DEBIT",
"amount": -6000,
"currency": "XOF",
"reason": "TICKET_ESCROW",
"reference_type": "TICKET",
"reference_id": "…",
"created_at": "2026-08-10T09:12:44Z"
},
{
"id": "…",
"direction": "CREDIT",
"amount": 10000,
"currency": "XOF",
"reason": "TOPUP",
"reference_type": "PAYMENT",
"reference_id": "MTX-…",
"created_at": "2026-08-10T09:01:02Z"
}
]
}
}
amount est signé du point de vue du client : négatif quand il paie, positif quand il
reçoit. Affichable directement, sans recalcul.
reason possibles : TOPUP, TICKET_ESCROW, TICKET_RELEASE, TICKET_REFUND,
PLATFORM_FEE, WITHDRAWAL, ADMIN_CREDIT, ADMIN_DEBIT.
Un client qui n’a jamais rechargé n’a pas encore de compte : la réponse renvoie
balance: 0etentries: []. Ce n’est pas une erreur, il ne faut pas afficher d’échec.
2. Recharger le solde
POST /api/v1/support/wallet/topup
{ "amount": 10000, "provider": "fedapay", "return_url": "mikhmoai://wallet/success" }
provider : fedapay (défaut) ou geniuspay.
Bornes : 500 à 2 000 000 XOF (WALLET_TOPUP_MIN_XOF / _MAX_XOF).
{
"success": true,
"data": {
"checkout_url": "https://…",
"internal_tx_id": "WLT_1786…_a1b2c3",
"provider": "fedapay",
"amount": 10000,
"currency": "XOF"
}
}
Le solde n’est PAS crédité à cet instant. Cette route ouvre seulement un paiement. Le crédit intervient quand le prestataire confirme, par webhook. Un client qui abandonne le paiement n’a rien reçu — c’est voulu.
Parcours attendu côté app
- Appeler
/wallet/topup, ouvrircheckout_url. - Au retour sur
return_url, ne pas supposer que c’est payé. - Rafraîchir
/wallet/me: si le solde n’a pas bougé, réessayer après quelques secondes (le webhook arrive en général en 2 à 5 s). Trois tentatives espacées suffisent. - Le montant crédité est celui que le prestataire confirme, jamais celui envoyé dans la requête. Inutile de le recalculer côté app.
⚠️ Envoyer l’en-tête
Authorizationsur/wallet/topup. Si le jeton manque, la requête est rejetée en 401 : il n’existe aucun chemin « invité » pour un rechargement. C’est délibéré — un rechargement anonyme créditerait un portefeuille sans propriétaire.
3. Poster une mission rémunérée
POST /api/v1/support/wallet/tickets
{
"subject": "Configuration hotspot MikroTik hAP ax3",
"description": "Portail captif + limitation de débit par profil.",
"category": "MIKROTIK",
"price_amount": 6000,
"is_urgent": true
}
price_amount est libre : c’est le montant que le client propose. is_urgent remonte la
mission dans la file des experts.
201 Created — la mission est ouverte et la somme bloquée :
{
"success": true,
"data": {
"id": "…",
"status": "OPEN",
"payment_status": "ESCROWED",
"price_amount": 6000,
"is_urgent": true,
"currency": "XOF"
}
}
400 — solde insuffisant (le cas à soigner dans l’interface) :
{
"success": false,
"error": {
"code": "SOLDE_INSUFFISANT",
"message": "Solde insuffisant. Rechargez votre portefeuille.",
"balance": 2500,
"required": 6000,
"missing": 3500
}
}
missing est fourni pour proposer directement « Recharger 3 500 XOF » sans calcul côté app.
Autres codes : SUJET_REQUIS, PRIX_INVALIDE, UNAUTHENTICATED.
La mission n’est visible des experts qu’après le blocage des fonds. Si le blocage échoue, le ticket est annulé et rien n’est publié — jamais de mission « fantôme » sans argent.
Mission gratuite
Une demande de support ordinaire reste sur POST /support/api/tickets. Cette route crée
toujours un ticket gratuit : price_amount et payment_status envoyés dans le corps y
sont ignorés. Pour une mission rémunérée, il faut passer par /wallet/tickets.
4. Clôturer une mission
POST /api/v1/support/wallet/tickets/:id/settle { "expert_id": "…" } # expert payé
POST /api/v1/support/wallet/tickets/:id/refund { "raison": "…" } # client remboursé
Au règlement, la somme séquestrée est répartie entre l’expert et la commission de la
plateforme (15 % par défaut, TICKET_PLATFORM_FEE_PCT) :
{ "success": true, "data": { "partExpert": 5100, "commission": 900, "currency": "XOF" } }
Les deux opérations sont idempotentes : rejouer un règlement ne paie pas l’expert deux fois, rejouer un remboursement ne rembourse pas deux fois. L’app peut donc réessayer sans risque après une coupure réseau.
Un ticket qui n’est pas en ESCROWED renvoie { "ignore": true, "raison": "<statut>" } avec
un code 200 — ce n’est pas une erreur, c’est une opération déjà faite.
5. États d’une mission
payment_status |
Sens | Ce que l’app affiche |
|---|---|---|
FREE |
mission gratuite | rien de particulier |
ESCROWED |
somme bloquée | « Paiement sécurisé » |
RELEASED |
expert payé | « Réglée » |
REFUNDED |
client remboursé | « Remboursée » |
status du ticket : PENDING_PAYMENT (transitoire, jamais visible d’un expert) → OPEN →
… → CLOSED / CANCELLED.
6. Erreurs communes
| Code HTTP | error.code |
Que faire |
|---|---|---|
| 401 | UNAUTHENTICATED |
jeton absent ou expiré — reconnecter |
| 400 | SOLDE_INSUFFISANT |
proposer un rechargement de missing |
| 400 | MONTANT_HORS_BORNES |
rappeler les bornes 500 – 2 000 000 |
| 400 | PRESTATAIRE_INCONNU |
fedapay ou geniuspay uniquement |
| 429 | — | quota atteint : attendre, ne pas réessayer en boucle |
| 502 | CHECKOUT_INDISPONIBLE |
prestataire injoignable — proposer l’autre |
À propos du 429
Les routes qui engagent de l’argent sont limitées par utilisateur (5 actions par minute), pour empêcher le double clic de créer deux paiements. Un 429 ici n’est pas un incident : il faut désactiver le bouton pendant l’appel plutôt que de relancer automatiquement.
⚠️ Attention distincte : le quota global de l’API est de 100 requêtes / 15 min par IP, partagé par TOUTES les routes. Un écran qui sonde une route toutes les 15 secondes consomme à lui seul l’intégralité du budget et fait échouer le reste — y compris ces routes de portefeuille. Espacer les sondages à 60 s minimum et les couper en arrière-plan.
7. Garanties du serveur
Ces points sont assurés côté backend ; l’app n’a rien à réimplémenter.
- Aucun double crédit. Chaque mouvement porte une clé d’idempotence unique en base. Un webhook rejoué, un double clic, un réconciliateur qui repasse : la base refuse le doublon.
- Aucun découvert. Un solde ne peut pas devenir négatif : c’est une contrainte de la base, pas une vérification applicative — donc insensible aux accès concurrents.
- Historique inaltérable. Les mouvements ne peuvent être ni modifiés ni supprimés. Une erreur se corrige par une écriture inverse, qui reste visible.
- Comptabilité vérifiable. La somme de tous les soldes vaut toujours zéro ; un écart signalerait une corruption et déclenche une alerte.
- Le montant fait foi côté prestataire. Manipuler la requête de rechargement ne permet pas de se créditer plus que ce qui a été payé.