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é.

Cette page documente le contrat partenaire v5, en vigueur depuis le 17 mai 2026. La forme du fil (requêtes, réponses, payloads webhook) est identique à v4 ; v5 ajoute la réalité opérationnelle (états bloqués, source admin_manual, comportement réel de Bazik). Le contrat technique complet vit dans wire-contract-v5.md.

Qui possède quoi

Nom / CodeType / LimiteDescription
Authentification utilisateurMCCSupabase auth ; OAuth émis pour des utilisateurs MCC.
Autorisation par-payoutPartenaireVous collectez le consentement dans votre propre produit, puis demandez à MCC d'exécuter.
Solde HTGMCCTenu dans le ledger MCC. Débité à l'exécution.
Plafond journalier partenaireMCCuser_connected_apps.daily_limit_htg
Plafond plan utilisateurMCCplans.max_withdraw_htg_per_day
Plancher / plafond par-payoutMCC1 000 HTG plancher · 75 000 HTG plafond Bazik
Exécution Bazik / MonCashMCCVia le cron exécuteur + bazik-webhook + fallback admin_manual
Rapport de statut / webhooksMCCÉvénements signés HMAC vers votre URL enregistrée
Récompenses / fees / commissionsPartenaireMCC ne stocke ni n'écho aucun champ de récompense.
Identité expéditeurPartenaireMCC 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 / CodeType / LimiteDescription
balance.readscopeLire le solde HTG de l'utilisateur et son plafond plan.
payout.requestscopeCréer des payouts au nom de l'utilisateur — requis pour external-payout-create.
payout.status.readscopeSonder external-payout-status pour les payouts créés sur cette connexion.
Réémettre un token pour le même 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

POST
https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1/external-payout-create

Corps canonique

Nom / CodeType / LimiteDescription
external_reference*string≤ 128 chars. Clé d'idempotence. Unique par user_connected_app_id.
amount_htg*integer1 000 ≤ value ≤ 75 000.
recipient_moncash_number*string8–15 chiffres. Préfixe + optionnel.
recipient_namestring≤ 200 chars. Affiché à l'utilisateur dans l'UI MCC.
descriptionstring≤ 280 chars. Affiché à l'utilisateur dans l'UI MCC.
metadataobjectArbitraire. ≤ 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.

Convention différente sur le flux marchand /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

GET
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

Pour les payouts MonCash sortants via le compte Bazik 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 / CodeType / LimiteDescription
POST /external-payout-create240/min IP600/min par app · 30/min par connexion
GET /external-payout-status240/min IP600/min par app · 60/min par connexion

Vocabulaire de statut

Valeurs de statut sur le fil

Nom / CodeType / LimiteDescription
pending_agent_approvalnon-terminalL'utilisateur a opté pour la 2e couche d'approbation et n'a pas agi.
agent_approvednon-terminalUtilisateur a approuvé (ou auto-approval s'applique). L'exécuteur va prendre le relais.
processing_by_moncashconnectnon-terminalMCC exécute activement ce payout.
submitted_to_baziknon-terminalBazik a renvoyé 200 OK. Confirmation de livraison en attente — peut prendre des heures.
completedterminalLivraison confirmée. La source de confirmation (bazik_webhook ou admin_manual) n'est pas exposée.
failedterminalExécuteur ou Bazik a rejeté. Fonds non débités ou remboursés.
rejected_by_agentterminalUtilisateur a refusé sur l'écran de consentement.
cancelledterminalAnnulé avant exécution, ou connexion révoquée pendant que le payout était en attente.
expiredterminalFenê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 / CodeType / LimiteDescription
bazik_webhookfast pathBazik a POSTé une confirmation de livraison vers bazik-webhook. Le chemin rapide.
bazik_pollréservéRéservé — Bazik n'expose pas de GET de statut fonctionnel pour les payouts sortants (cf. §lire le statut).
admin_manualfallbackOps 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 / CodeType / LimiteDescription
X-MCC-Event-IduuidID unique de l'événement — utilisez-le pour la déduplication.
X-MCC-TimestampstringTimestamp Unix (secondes). Rejetez si > 5 min d'écart.
X-MCC-Signaturestringt=<unix>,v1=<hex(HMAC-SHA256(secret, "<unix>.<body>"))>
Content-Typestringapplication/json
Le schéma de signature Connect (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 / CodeType / LimiteDescription
external_payout.createdlifecycleEPR inséré.
external_payout.agent_approvedlifecycleStatut passé à agent_approved (ou auto_approved à l'insertion).
external_payout.submitted_to_baziklifecycleBazik a retourné 200 OK ; en attente de confirmation de livraison.
external_payout.completedterminalLivraison confirmée. Source non exposée sur le fil.
external_payout.failedterminalÉchec exécuteur ou livraison. Fonds non débités / remboursés.
external_payout.rejectedterminalUtilisateur a refusé sur l'écran de consentement.
external_payout.cancelledterminalAnnulé, y compris annulation par révocation de connexion.
external_payout.expiredterminalFenêtre d'approbation de 24h dépassée.

Exemple de payload

external_payout.completed
L'enveloppe (event_id, event_type, occurred_at) entoure une partner-view dans data.
{
  "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 / CodeType / LimiteDescription
executor_errorinterne MCCÉchec de l'exécuteur avant soumission Bazik. Fonds remboursés.
delivery_failedBazik/MonCashBazik ou MonCash a rejeté la livraison. Fonds remboursés.
connection_revokedutilisateurUtilisateur a révoqué la connexion pendant le traitement. Fonds remboursés.
insufficient_balanceutilisateurSolde MCC insuffisant à l'exécution (rare — attrapé à l'intake).
below_minimumvalidationMontant en-dessous du plancher 1 000 HTG à l'exécution.
rejected_by_agentutilisateurUtilisateur a refusé sur l'écran de consentement.
failedlegacyNon 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_at si vous suivez les transitions ; traitez data.status comme 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_id doit 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 / CodeType / LimiteDescription
created → agent_approvedinstantanéAuto-approval applique dans la même requête de création.
agent_approved → submitted_to_bazik0–60sL'exécuteur prend la file ; reconciler Phase B couvre les retards.
submitted_to_bazik → completed0–10 minQuand le webhook Bazik s'active correctement (cas courant).
submitted_to_bazik → completed (long)10 min – plusieurs heuresQuand 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_manual si 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_reference si 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 / CodeType / LimiteDescription
external_payout_stuck_pre_bazikauditLigne restée à agent_approved au-delà de la fenêtre d'attente. Le reconciler Phase B auto-soumet.
external_payout_stuck_in_sendingauditDans l'étape send-to-Bazik plus longtemps que prévu.
external_payout_stuck_confirmationauditÀ submitted_to_bazik plus longtemps que prévu, avec un Bazik withdrawal_id déjà émis.
external_payout_unconfirmed_too_longauditÀ 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 / CodeType / LimiteDescription
invalid_request400Corps mal formé, champ canonique manquant, valeur hors plage. error_description explique.
amount_below_minimum400amount_htg < 1 000.
invalid_token401Bearer manquant, token inconnu, révoqué ou expiré.
insufficient_scope403Le token n'a pas le scope requis (payout.request, payout.status.read).
connection_revoked403L'utilisateur a révoqué la connexion côté MCC après émission du token.
connection_not_found404Le user_connected_app_id du token n'existe plus.
partner_daily_cap_exceeded409Plafond journalier par-user de votre application dépassé. NE PAS retry avec le même external_reference.
plan_daily_cap_exceeded409Plafond du plan de l'utilisateur dépassé.
rate_limit_exceeded429Par-IP (240/min), par-app (600/min), ou par-connexion (30/min).
internal_error500Bug MCC. Safe à retry avec le même external_reference.
auth_unavailable503Infra transitoire. Safe à retry.
rate_limit_unavailable503Infra transitoire. Safe à retry.
{
  "error":             "insufficient_scope",
  "error_description": "Token lacks 'payout.request' scope"
}