MonCashConnect Connect
Connect permet à votre application d'exécuter des payouts MonCash sortants depuis le solde HTG d'un utilisateur MonCashConnect. Modèle mental : MonCashConnect est à un produit de remise ce qu'un réseau de cartes est à un commerçant. Votre application connaît à qui appartient l'argent et pourquoi ; MCC valide la source de fonds, vérifie la limite, et confirme que le rail a accepté.
admin_manual, comportement réel de Bazik). Le contrat technique complet vit dans wire-contract-v5.md.Qui possède quoi
| Nom / Code | Type / Limite | Description |
|---|---|---|
| Authentification utilisateur | MCC | Supabase auth ; OAuth émis pour des utilisateurs MCC. |
| Autorisation par-payout | Partenaire | Vous collectez le consentement dans votre propre produit, puis demandez à MCC d'exécuter. |
| Solde HTG | MCC | Tenu dans le ledger MCC. Débité à l'exécution. |
| Plafond journalier partenaire | MCC | user_connected_apps.daily_limit_htg |
| Plafond plan utilisateur | MCC | plans.max_withdraw_htg_per_day |
| Plancher / plafond par-payout | MCC | 1 000 HTG plancher · 75 000 HTG plafond Bazik |
| Exécution Bazik / MonCash | MCC | Via le cron exécuteur + bazik-webhook + fallback admin_manual |
| Rapport de statut / webhooks | MCC | Événements signés HMAC vers votre URL enregistrée |
| Récompenses / fees / commissions | Partenaire | MCC ne stocke ni n'écho aucun champ de récompense. |
| Identité expéditeur | Partenaire | MCC ne stocke ni n'écho. Stockez côté partenaire, indexé sur external_reference. |
OAuth flow
RFC 6749 authorization-code flow avec authentification client HTTP Basic. Pas de refresh tokens. Codes uniques à usage unique, valides 60 secondes ; access tokens valides 30 jours.
1. Authorize
GET https://moncashconnect.com/connect/authorize
?response_type=code
&client_id=<public_client_id>
&redirect_uri=<exact match de callback_url>
&scope=<scopes séparés par espace>
&state=<opaque, contrôlé par le partenaire>L'utilisateur signe in s'il ne l'est pas déjà, voit l'écran de consentement, choisit un plafond journalier, clique Autoriser. L'UX par défaut est gateway-only (requires_user_approval = false) ; l'utilisateur peut opter pour une seconde couche d'approbation, mais votre application ne doit pas en dépendre.
Sur approbation → redirect vers redirect_uri?code=<raw>&state=<state>. Sur refus → redirect vers redirect_uri?error=access_denied&state=<state>.
2. Échange du code contre un token
POST https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1/oauth-token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<raw>
&redirect_uri=<exact match de l'authorize redirect_uri>{
"access_token": "<43 caractères URL-safe>",
"token_type": "Bearer",
"expires_in": 2592000,
"scope": "payout.request"
}3. Scopes
| Nom / Code | Type / Limite | Description |
|---|---|---|
| balance.read | scope | Lire le solde HTG de l'utilisateur et son plafond plan. |
| payout.request | scope | Créer des payouts au nom de l'utilisateur — requis pour external-payout-create. |
| payout.status.read | scope | Sonder external-payout-status pour les payouts créés sur cette connexion. |
user_connected_app_id révoque automatiquement le token actif précédent. Stockez un seul token par connexion à la fois côté partenaire.Créer un payout
https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1/external-payout-createCorps canonique
| Nom / Code | Type / Limite | Description |
|---|---|---|
| external_reference* | string | ≤ 128 chars. Clé d'idempotence. Unique par user_connected_app_id. |
| amount_htg* | integer | 1 000 ≤ value ≤ 75 000. |
| recipient_moncash_number* | string | 8–15 chiffres. Préfixe + optionnel. |
| recipient_name | string | ≤ 200 chars. Affiché à l'utilisateur dans l'UI MCC. |
| description | string | ≤ 280 chars. Affiché à l'utilisateur dans l'UI MCC. |
| metadata | object | Arbitraire. ≤ 4 KB stringifié. Stocké tel quel sur l'EPR. Pas écho via webhook. |
Parsing tolérant. Toute clé JSON hors de la liste canonique ci-dessus est lue et écartée. Pas de validation, pas d'erreur, pas de stockage, pas d'écho. Garantie de compatibilité pour partenaires migrant depuis v3.
Réponse
201 Created pour une nouvelle requête, 200 OK sur replay idempotent (matched external_reference) avec is_replay: true.
{
"id": "5c715691-2953-4a35-8f72-ff817aefeb12",
"status": "auto_approved",
"amount_htg": 1000,
"currency": "HTG",
"recipient_moncash_number_last4": "4567",
"external_reference": "smoke-stage6-1778177771",
"is_sandbox": false,
"is_replay": false,
"created_at": "2026-05-07T18:16:13.131436+00:00",
"expires_at": null
}Idempotence — différence avec /pay-create
Les replays sont matchés sur (user_connected_app_id, external_reference). Un deuxième POST avec le même external_reference retourne l'état courant du payout original (200 OK, is_replay: true). Le corps du replay n'est pas consulté — les champs que vous auriez modifiés sont ignorés. Pour un retry avec des champs différents, générez un nouvel external_reference.
/pay-create. Cet endpoint traite un referenceId dupliqué comme une erreur (409 Conflict), pas comme un replay. Codez vos retries en fonction.Lire le statut
https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1/external-payout-status?external_reference=<...>Requiert le scope payout.status.read. Retourne la même forme que la partner-view (cf. §webhooks) pour tout payout créé précédemment sur cette connexion.
Réalité opérationnelle — Bazik n'a pas de status GET
online, Bazik n'expose actuellement pas de GET de statut fonctionnel. L'exécuteur MCC soumet le retrait puis attend l'une des trois sources de confirmation (cf. §vocabulaire) pour basculer la ligne. En pratique, un payout peut rester à submitted_to_bazik pendant des minutes ou des heures.Ce n'est pas un bug. Reposez-vous sur les webhooks pour les transitions ; le polling n'est utile que pour réconcilier des événements manqués.
Limites de débit Connect
| Nom / Code | Type / Limite | Description |
|---|---|---|
| POST /external-payout-create | 240/min IP | 600/min par app · 30/min par connexion |
| GET /external-payout-status | 240/min IP | 600/min par app · 60/min par connexion |
Vocabulaire de statut
Valeurs de statut sur le fil
| Nom / Code | Type / Limite | Description |
|---|---|---|
| pending_agent_approval | non-terminal | L'utilisateur a opté pour la 2e couche d'approbation et n'a pas agi. |
| agent_approved | non-terminal | Utilisateur a approuvé (ou auto-approval s'applique). L'exécuteur va prendre le relais. |
| processing_by_moncashconnect | non-terminal | MCC exécute activement ce payout. |
| submitted_to_bazik | non-terminal | Bazik a renvoyé 200 OK. Confirmation de livraison en attente — peut prendre des heures. |
| completed | terminal | Livraison confirmée. La source de confirmation (bazik_webhook ou admin_manual) n'est pas exposée. |
| failed | terminal | Exécuteur ou Bazik a rejeté. Fonds non débités ou remboursés. |
| rejected_by_agent | terminal | Utilisateur a refusé sur l'écran de consentement. |
| cancelled | terminal | Annulé avant exécution, ou connexion révoquée pendant que le payout était en attente. |
| expired | terminal | Fenêtre d'approbation de 24h dépassée sans action utilisateur. |
Tous les statuts terminaux sont stables — une fois atteints, MCC ne changera plus le statut.
Sources de confirmation pour completed
Un payout passé à completed a été confirmé par l'une de trois sources. Les partenaires ne voient pas laquelle — le statut sur le fil est completed dans tous les cas.
| Nom / Code | Type / Limite | Description |
|---|---|---|
| bazik_webhook | fast path | Bazik a POSTé une confirmation de livraison vers bazik-webhook. Le chemin rapide. |
| bazik_poll | réservé | Réservé — Bazik n'expose pas de GET de statut fonctionnel pour les payouts sortants (cf. §lire le statut). |
| admin_manual | fallback | Ops MCC a manuellement confirmé après vérification indépendante (dashboard Bazik, confirmation destinataire). Peut survenir des heures après la livraison réelle. |
Le occurred_at du webhook reflète quand MCC a marqué la ligne comme complétée, pas quand la livraison MonCash réelle s'est produite. Les deux peuvent être à des heures d'écart pour admin_manual.
Webhooks
MCC POST des événements signés HMAC vers l'URL configurée dans connected_apps.webhook_url.
En-têtes envoyés
| Nom / Code | Type / Limite | Description |
|---|---|---|
| X-MCC-Event-Id | uuid | ID unique de l'événement — utilisez-le pour la déduplication. |
| X-MCC-Timestamp | string | Timestamp Unix (secondes). Rejetez si > 5 min d'écart. |
| X-MCC-Signature | string | t=<unix>,v1=<hex(HMAC-SHA256(secret, "<unix>.<body>"))> |
| Content-Type | string | application/json |
t=…,v1=…) est différent du flux marchand bazik-webhook utilisé par /pay-create (signe sha256=<hex> sur le corps brut, sans X-MCC-Event-Id). Si vous gérez les deux flux, écrivez deux vérificateurs.Vérification de la signature
import crypto from "node:crypto";
// Lisez le corps en bytes bruts AVANT toute désérialisation
const rawBody = Buffer.from(await req.text(), "utf8");
const sigHdr = req.headers["x-mcc-signature"]; // "t=...,v1=..."
const eventId = req.headers["x-mcc-event-id"];
const parts = Object.fromEntries(
sigHdr.split(",").map((p) => p.split("=")),
);
const ts = parts.t, v1 = parts.v1;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("timestamp drift");
}
const expected = crypto
.createHmac("sha256", process.env.MCC_WEBHOOK_SECRET)
.update(`${ts}.${rawBody.toString("utf8")}`)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex"),
)) throw new Error("bad signature");
// Dédupliquez sur eventId avant de traiter
if (await alreadyProcessed(eventId)) return res.sendStatus(200);Événements
| Nom / Code | Type / Limite | Description |
|---|---|---|
| external_payout.created | lifecycle | EPR inséré. |
| external_payout.agent_approved | lifecycle | Statut passé à agent_approved (ou auto_approved à l'insertion). |
| external_payout.submitted_to_bazik | lifecycle | Bazik a retourné 200 OK ; en attente de confirmation de livraison. |
| external_payout.completed | terminal | Livraison confirmée. Source non exposée sur le fil. |
| external_payout.failed | terminal | Échec exécuteur ou livraison. Fonds non débités / remboursés. |
| external_payout.rejected | terminal | Utilisateur a refusé sur l'écran de consentement. |
| external_payout.cancelled | terminal | Annulé, y compris annulation par révocation de connexion. |
| external_payout.expired | terminal | Fenêtre d'approbation de 24h dépassée. |
Exemple de payload
{
"event_id": "f8e2d1c0-1234-5678-9abc-def012345678",
"event_type": "external_payout.completed",
"occurred_at": "2026-05-07T18:16:13Z",
"data": {
"id": "5c715691-2953-4a35-8f72-ff817aefeb12",
"external_reference": "smoke-stage6-1778177771",
"status": "completed",
"amount_htg": 1000,
"currency": "HTG",
"recipient_moncash_number_last4": "4567",
"recipient_name": "Camy Peter",
"is_sandbox": false,
"created_at": "2026-05-07T18:14:02Z",
"updated_at": "2026-05-07T18:16:13Z",
"delivery_confirmed_at": "2026-05-07T18:16:13Z",
"delivery_failed_at": null,
"failure_reason": null
}
}Vocabulaire de failure_reason
Quand data.status = "failed", ce champ porte l'une de :
| Nom / Code | Type / Limite | Description |
|---|---|---|
| executor_error | interne MCC | Échec de l'exécuteur avant soumission Bazik. Fonds remboursés. |
| delivery_failed | Bazik/MonCash | Bazik ou MonCash a rejeté la livraison. Fonds remboursés. |
| connection_revoked | utilisateur | Utilisateur a révoqué la connexion pendant le traitement. Fonds remboursés. |
| insufficient_balance | utilisateur | Solde MCC insuffisant à l'exécution (rare — attrapé à l'intake). |
| below_minimum | validation | Montant en-dessous du plancher 1 000 HTG à l'exécution. |
| rejected_by_agent | utilisateur | Utilisateur a refusé sur l'écran de consentement. |
| failed | legacy | Non spécifié / legacy. La ligne est terminale ; traitez comme signal souple. |
Sémantique de livraison
- At-least-once. Une 2xx du partenaire stoppe les retries ; sinon retry exponentiel jusqu'à un cap d'attempts.
- Livraison hors-ordre possible. Triez par
occurred_atsi vous suivez les transitions ; traitezdata.statuscomme autoritatif. - Dédupliquez sur
event_id. Même id = même événement, même si le payload diffère entre tentatives. - Réception idempotente. Traiter deux fois le même
event_iddoit produire le même résultat.
Attentes opérationnelles
Le fil donne le statut, pas la latence. Voici ce qui est normal et ce qui ne l'est pas.
SLA happy-path typique
| Nom / Code | Type / Limite | Description |
|---|---|---|
| created → agent_approved | instantané | Auto-approval applique dans la même requête de création. |
| agent_approved → submitted_to_bazik | 0–60s | L'exécuteur prend la file ; reconciler Phase B couvre les retards. |
| submitted_to_bazik → completed | 0–10 min | Quand le webhook Bazik s'active correctement (cas courant). |
| submitted_to_bazik → completed (long) | 10 min – plusieurs heures | Quand le webhook Bazik n'arrive pas ; résolu via admin_manual. |
Que faire si un payout reste à submitted_to_bazik ≥ 30 min
- N'annulez pas, ne réessayez pas. Les fonds sont déjà débités du solde MCC de l'utilisateur ; le payout sera complété (via
admin_manualsi nécessaire) ou remboursé. - Affichez "en cours" côté UX, pas "échoué". Le statut sur le fil reste non-terminal jusqu'à résolution.
- Contactez le support MCC avec le
external_referencesi la latence dépasse votre fenêtre tolérable. Ops vérifiera indépendamment et déclenchera la confirmation manuelle si la livraison Bazik est confirmée.
États bloqués internes (pas sur le fil)
MCC marque certains payouts en lingering avec un drapeau interne risk_review_required. Vous ne voyez pas ces codes — le statut sur le fil reste submitted_to_bazik (ou autre selon le cas). Référence pour le support :
| Nom / Code | Type / Limite | Description |
|---|---|---|
| external_payout_stuck_pre_bazik | audit | Ligne restée à agent_approved au-delà de la fenêtre d'attente. Le reconciler Phase B auto-soumet. |
| external_payout_stuck_in_sending | audit | Dans l'étape send-to-Bazik plus longtemps que prévu. |
| external_payout_stuck_confirmation | audit | À submitted_to_bazik plus longtemps que prévu, avec un Bazik withdrawal_id déjà émis. |
| external_payout_unconfirmed_too_long | audit | À submitted_to_bazik au-delà de la fenêtre absolue d'escalade. |
Codes d'erreur
Toutes les erreurs renvoient un objet JSON avec un champ error (code machine-stable) et un champ error_description (humain).
| Nom / Code | Type / Limite | Description |
|---|---|---|
| invalid_request | 400 | Corps mal formé, champ canonique manquant, valeur hors plage. error_description explique. |
| amount_below_minimum | 400 | amount_htg < 1 000. |
| invalid_token | 401 | Bearer manquant, token inconnu, révoqué ou expiré. |
| insufficient_scope | 403 | Le token n'a pas le scope requis (payout.request, payout.status.read). |
| connection_revoked | 403 | L'utilisateur a révoqué la connexion côté MCC après émission du token. |
| connection_not_found | 404 | Le user_connected_app_id du token n'existe plus. |
| partner_daily_cap_exceeded | 409 | Plafond journalier par-user de votre application dépassé. NE PAS retry avec le même external_reference. |
| plan_daily_cap_exceeded | 409 | Plafond du plan de l'utilisateur dépassé. |
| rate_limit_exceeded | 429 | Par-IP (240/min), par-app (600/min), ou par-connexion (30/min). |
| internal_error | 500 | Bug MCC. Safe à retry avec le même external_reference. |
| auth_unavailable | 503 | Infra transitoire. Safe à retry. |
| rate_limit_unavailable | 503 | Infra transitoire. Safe à retry. |
{
"error": "insufficient_scope",
"error_description": "Token lacks 'payout.request' scope"
}