MonCashConnect Connect
Connect lets your application execute outbound MonCash payouts from the HTG balance of a MonCashConnect user. Mental model: MonCashConnect is to a remittance product what a card network is to a merchant. Your app knows whose money it is and why; MCC validates the source of funds, checks the limit, and confirms the rail accepted.
admin_manual source, Bazik's real behavior). The full technical contract lives in wire-contract-v5.md.Who owns what
| Nom / Code | Type / Limite | Description |
|---|---|---|
| User authentication | MCC | Supabase auth; OAuth issued for MCC users. |
| Per-payout authorization | Partner | You collect consent inside your own product, then ask MCC to execute. |
| HTG balance | MCC | Held in the MCC ledger. Debited on execution. |
| Partner daily cap | MCC | user_connected_apps.daily_limit_htg |
| User plan cap | MCC | plans.max_withdraw_htg_per_day |
| Per-payout floor / ceiling | MCC | 1 000 HTG floor · 75 000 HTG Bazik ceiling |
| Bazik / MonCash execution | MCC | Via the executor cron + bazik-webhook + admin_manual fallback |
| Status reporting / webhooks | MCC | HMAC-signed events to your registered URL |
| Rewards / fees / commissions | Partner | MCC neither stores nor echoes any reward field. |
| Sender identity | Partner | MCC neither stores nor echoes. Store on your side, indexed on external_reference. |
OAuth flow
RFC 6749 authorization-code flow with HTTP Basic client authentication. No refresh tokens. Codes are single-use, valid for 60 seconds; access tokens are valid for 30 days.
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>The user signs in if they aren't already, sees the consent screen, picks a daily cap, clicks Authorize. The default UX is gateway-only (requires_user_approval = false); the user can opt into a second approval layer, but your application must not depend on it.
On approval → redirect to redirect_uri?code=<raw>&state=<state>. On refusal → redirect to redirect_uri?error=access_denied&state=<state>.
2. Exchange the code for a 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 | Read the user's HTG balance and their plan cap. |
| payout.request | scope | Create payouts on behalf of the user — required for external-payout-create. |
| payout.status.read | scope | Poll external-payout-status for payouts created on this connection. |
user_connected_app_id automatically revokes the previously active token. Store a single token per connection at a time on the partner side.Create a payout
https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1/external-payout-createCanonical body
| Nom / Code | Type / Limite | Description |
|---|---|---|
| external_reference* | string | ≤ 128 chars. Idempotency key. Unique per user_connected_app_id. |
| amount_htg* | integer | 1 000 ≤ value ≤ 75 000. |
| recipient_moncash_number* | string | 8–15 digits. Optional + prefix. |
| recipient_name | string | ≤ 200 chars. Shown to the user in the MCC UI. |
| description | string | ≤ 280 chars. Shown to the user in the MCC UI. |
| metadata | object | Arbitrary. ≤ 4 KB stringified. Stored as-is on the EPR. Not echoed via webhook. |
Tolerant parsing. Any JSON key outside the canonical list above is read and discarded. No validation, no error, no storage, no echo. Compatibility guarantee for partners migrating from v3.
Response
201 Created for a new request, 200 OK on an idempotent replay (matched external_reference) with 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
}Idempotency — difference vs. /pay-create
Replays are matched on (user_connected_app_id, external_reference). A second POST with the same external_reference returns the current state of the original payout (200 OK, is_replay: true). The replay body is not consulted — any fields you may have modified are ignored. To retry with different fields, generate a new external_reference.
/pay-create. That endpoint treats a duplicate referenceId as an error (409 Conflict), not as a replay. Code your retries accordingly.Read status
https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1/external-payout-status?external_reference=<...>Requires the payout.status.read scope. Returns the same shape as the partner-view (cf. §webhooks) for any payout previously created on this connection.
Operational reality — Bazik has no status GET
online account, Bazik currently exposes no functional status GET. The MCC executor submits the withdrawal then waits for one of three confirmation sources (cf. §vocabulary) to flip the row. In practice, a payout can sit at submitted_to_bazik for minutes or hours.This is not a bug. Rely on webhooks for transitions; polling is only useful to reconcile missed events.
Connect rate limits
| Nom / Code | Type / Limite | Description |
|---|---|---|
| POST /external-payout-create | 240/min IP | 600/min per app · 30/min per connection |
| GET /external-payout-status | 240/min IP | 600/min per app · 60/min per connection |
Status vocabulary
Status values on the wire
| Nom / Code | Type / Limite | Description |
|---|---|---|
| pending_agent_approval | non-terminal | The user opted into the 2nd approval layer and hasn't acted. |
| agent_approved | non-terminal | User approved (or auto-approval applies). The executor will take over. |
| processing_by_moncashconnect | non-terminal | MCC is actively executing this payout. |
| submitted_to_bazik | non-terminal | Bazik returned 200 OK. Delivery confirmation pending — can take hours. |
| completed | terminal | Delivery confirmed. The confirmation source (bazik_webhook or admin_manual) is not exposed. |
| failed | terminal | Executor or Bazik rejected. Funds not debited or refunded. |
| rejected_by_agent | terminal | User declined on the consent screen. |
| cancelled | terminal | Cancelled before execution, or connection revoked while the payout was pending. |
| expired | terminal | 24h approval window elapsed without user action. |
All terminal statuses are stable — once reached, MCC will not change the status again.
Confirmation sources for completed
A payout moved to completed was confirmed by one of three sources. Partners do not see which one — the status on the wire is completed in every case.
| Nom / Code | Type / Limite | Description |
|---|---|---|
| bazik_webhook | fast path | Bazik POSTed a delivery confirmation to bazik-webhook. The fast path. |
| bazik_poll | reserved | Reserved — Bazik exposes no functional status GET for outbound payouts (cf. §read status). |
| admin_manual | fallback | MCC Ops manually confirmed after independent verification (Bazik dashboard, recipient confirmation). May occur hours after the actual delivery. |
The webhook's occurred_at reflects when MCC marked the row as completed, not when the actual MonCash delivery happened. The two can be hours apart for admin_manual.
Webhooks
MCC POSTs HMAC-signed events to the URL configured in connected_apps.webhook_url.
Headers sent
| Nom / Code | Type / Limite | Description |
|---|---|---|
| X-MCC-Event-Id | uuid | Unique event ID — use it for deduplication. |
| X-MCC-Timestamp | string | Unix timestamp (seconds). Reject if more than 5 min off. |
| X-MCC-Signature | string | t=<unix>,v1=<hex(HMAC-SHA256(secret, "<unix>.<body>"))> |
| Content-Type | string | application/json |
t=…,v1=…) is different from the merchant flow bazik-webhook used by /pay-create (which signs sha256=<hex> over the raw body, with no X-MCC-Event-Id). If you handle both flows, write two verifiers.Signature verification
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);Events
| Nom / Code | Type / Limite | Description |
|---|---|---|
| external_payout.created | lifecycle | EPR inserted. |
| external_payout.agent_approved | lifecycle | Status moved to agent_approved (or auto_approved at insertion). |
| external_payout.submitted_to_bazik | lifecycle | Bazik returned 200 OK; awaiting delivery confirmation. |
| external_payout.completed | terminal | Delivery confirmed. Source not exposed on the wire. |
| external_payout.failed | terminal | Executor or delivery failure. Funds not debited / refunded. |
| external_payout.rejected | terminal | User declined on the consent screen. |
| external_payout.cancelled | terminal | Cancelled, including cancellation via connection revocation. |
| external_payout.expired | terminal | 24h approval window elapsed. |
Example 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
}
}failure_reason vocabulary
When data.status = "failed", this field carries one of:
| Nom / Code | Type / Limite | Description |
|---|---|---|
| executor_error | MCC-internal | Executor failure before Bazik submission. Funds refunded. |
| delivery_failed | Bazik/MonCash | Bazik or MonCash rejected the delivery. Funds refunded. |
| connection_revoked | user | User revoked the connection during processing. Funds refunded. |
| insufficient_balance | user | Insufficient MCC balance at execution time (rare — caught at intake). |
| below_minimum | validation | Amount below the 1 000 HTG floor at execution time. |
| rejected_by_agent | user | User declined on the consent screen. |
| failed | legacy | Unspecified / legacy. The row is terminal; treat as a soft signal. |
Delivery semantics
- At-least-once. A 2xx from the partner stops retries; otherwise exponential retry up to an attempts cap.
- Out-of-order delivery is possible. Sort by
occurred_atif you track transitions; treatdata.statusas authoritative. - Deduplicate on
event_id. Same id = same event, even if the payload differs between attempts. - Idempotent reception. Processing the same
event_idtwice must produce the same result.
Operational expectations
The wire gives you status, not latency. Here's what's normal and what isn't.
Typical happy-path SLA
| Nom / Code | Type / Limite | Description |
|---|---|---|
| created → agent_approved | instant | Auto-approval applies in the same create request. |
| agent_approved → submitted_to_bazik | 0–60s | The executor picks up the queue; the Phase B reconciler covers delays. |
| submitted_to_bazik → completed | 0–10 min | When the Bazik webhook fires correctly (common case). |
| submitted_to_bazik → completed (long) | 10 min – several hours | When the Bazik webhook doesn't arrive; resolved via admin_manual. |
What to do if a payout stays at submitted_to_bazik ≥ 30 min
- Don't cancel, don't retry. The funds are already debited from the user's MCC balance; the payout will be completed (via
admin_manualif needed) or refunded. - Show "in progress" on the UX, not "failed". The status on the wire stays non-terminal until resolution.
- Contact MCC support with the
external_referenceif the latency exceeds your tolerable window. Ops will verify independently and trigger the manual confirmation if Bazik delivery is confirmed.
Internal stuck states (not on the wire)
MCC flags certain lingering payouts with an internal risk_review_required flag. You don't see these codes — the wire status stays submitted_to_bazik (or whatever applies). Support reference:
| Nom / Code | Type / Limite | Description |
|---|---|---|
| external_payout_stuck_pre_bazik | audit | Row stayed at agent_approved beyond the wait window. The Phase B reconciler auto-submits. |
| external_payout_stuck_in_sending | audit | Stuck in the send-to-Bazik step longer than expected. |
| external_payout_stuck_confirmation | audit | At submitted_to_bazik longer than expected, with a Bazik withdrawal_id already issued. |
| external_payout_unconfirmed_too_long | audit | At submitted_to_bazik beyond the absolute escalation window. |
Error codes
All errors return a JSON object with an error field (machine-stable code) and an error_description field (human-readable).
| Nom / Code | Type / Limite | Description |
|---|---|---|
| invalid_request | 400 | Malformed body, missing canonical field, value out of range. error_description explains. |
| amount_below_minimum | 400 | amount_htg < 1 000. |
| invalid_token | 401 | Bearer missing, token unknown, revoked, or expired. |
| insufficient_scope | 403 | The token lacks the required scope (payout.request, payout.status.read). |
| connection_revoked | 403 | The user revoked the connection on the MCC side after the token was issued. |
| connection_not_found | 404 | The token's user_connected_app_id no longer exists. |
| partner_daily_cap_exceeded | 409 | Your application's per-user daily cap exceeded. DO NOT retry with the same external_reference. |
| plan_daily_cap_exceeded | 409 | The user's plan cap exceeded. |
| rate_limit_exceeded | 429 | Per-IP (240/min), per-app (600/min), or per-connection (30/min). |
| internal_error | 500 | MCC bug. Safe to retry with the same external_reference. |
| auth_unavailable | 503 | Transient infra. Safe to retry. |
| rate_limit_unavailable | 503 | Transient infra. Safe to retry. |
{
"error": "insufficient_scope",
"error_description": "Token lacks 'payout.request' scope"
}