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.

This page documents the partner contract v5, in effect since May 17, 2026. The wire shape (requests, responses, webhook payloads) is identical to v4; v5 adds operational reality (stuck states, admin_manual source, Bazik's real behavior). The full technical contract lives in wire-contract-v5.md.

Who owns what

Nom / CodeType / LimiteDescription
User authenticationMCCSupabase auth; OAuth issued for MCC users.
Per-payout authorizationPartnerYou collect consent inside your own product, then ask MCC to execute.
HTG balanceMCCHeld in the MCC ledger. Debited on execution.
Partner daily capMCCuser_connected_apps.daily_limit_htg
User plan capMCCplans.max_withdraw_htg_per_day
Per-payout floor / ceilingMCC1 000 HTG floor · 75 000 HTG Bazik ceiling
Bazik / MonCash executionMCCVia the executor cron + bazik-webhook + admin_manual fallback
Status reporting / webhooksMCCHMAC-signed events to your registered URL
Rewards / fees / commissionsPartnerMCC neither stores nor echoes any reward field.
Sender identityPartnerMCC 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 / CodeType / LimiteDescription
balance.readscopeRead the user's HTG balance and their plan cap.
payout.requestscopeCreate payouts on behalf of the user — required for external-payout-create.
payout.status.readscopePoll external-payout-status for payouts created on this connection.
Reissuing a token for the same 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

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

Canonical body

Nom / CodeType / LimiteDescription
external_reference*string≤ 128 chars. Idempotency key. Unique per user_connected_app_id.
amount_htg*integer1 000 ≤ value ≤ 75 000.
recipient_moncash_number*string8–15 digits. Optional + prefix.
recipient_namestring≤ 200 chars. Shown to the user in the MCC UI.
descriptionstring≤ 280 chars. Shown to the user in the MCC UI.
metadataobjectArbitrary. ≤ 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.

Different convention on the merchant flow /pay-create. That endpoint treats a duplicate referenceId as an error (409 Conflict), not as a replay. Code your retries accordingly.

Read status

GET
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

For outbound MonCash payouts via the Bazik 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 / CodeType / LimiteDescription
POST /external-payout-create240/min IP600/min per app · 30/min per connection
GET /external-payout-status240/min IP600/min per app · 60/min per connection

Status vocabulary

Status values on the wire

Nom / CodeType / LimiteDescription
pending_agent_approvalnon-terminalThe user opted into the 2nd approval layer and hasn't acted.
agent_approvednon-terminalUser approved (or auto-approval applies). The executor will take over.
processing_by_moncashconnectnon-terminalMCC is actively executing this payout.
submitted_to_baziknon-terminalBazik returned 200 OK. Delivery confirmation pending — can take hours.
completedterminalDelivery confirmed. The confirmation source (bazik_webhook or admin_manual) is not exposed.
failedterminalExecutor or Bazik rejected. Funds not debited or refunded.
rejected_by_agentterminalUser declined on the consent screen.
cancelledterminalCancelled before execution, or connection revoked while the payout was pending.
expiredterminal24h 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 / CodeType / LimiteDescription
bazik_webhookfast pathBazik POSTed a delivery confirmation to bazik-webhook. The fast path.
bazik_pollreservedReserved — Bazik exposes no functional status GET for outbound payouts (cf. §read status).
admin_manualfallbackMCC 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 / CodeType / LimiteDescription
X-MCC-Event-IduuidUnique event ID — use it for deduplication.
X-MCC-TimestampstringUnix timestamp (seconds). Reject if more than 5 min off.
X-MCC-Signaturestringt=<unix>,v1=<hex(HMAC-SHA256(secret, "<unix>.<body>"))>
Content-Typestringapplication/json
The Connect signature scheme (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 / CodeType / LimiteDescription
external_payout.createdlifecycleEPR inserted.
external_payout.agent_approvedlifecycleStatus moved to agent_approved (or auto_approved at insertion).
external_payout.submitted_to_baziklifecycleBazik returned 200 OK; awaiting delivery confirmation.
external_payout.completedterminalDelivery confirmed. Source not exposed on the wire.
external_payout.failedterminalExecutor or delivery failure. Funds not debited / refunded.
external_payout.rejectedterminalUser declined on the consent screen.
external_payout.cancelledterminalCancelled, including cancellation via connection revocation.
external_payout.expiredterminal24h approval window elapsed.

Example payload

external_payout.completed
The envelope (event_id, event_type, occurred_at) wraps a partner-view in 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
  }
}

failure_reason vocabulary

When data.status = "failed", this field carries one of:

Nom / CodeType / LimiteDescription
executor_errorMCC-internalExecutor failure before Bazik submission. Funds refunded.
delivery_failedBazik/MonCashBazik or MonCash rejected the delivery. Funds refunded.
connection_revokeduserUser revoked the connection during processing. Funds refunded.
insufficient_balanceuserInsufficient MCC balance at execution time (rare — caught at intake).
below_minimumvalidationAmount below the 1 000 HTG floor at execution time.
rejected_by_agentuserUser declined on the consent screen.
failedlegacyUnspecified / 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_at if you track transitions; treat data.status as authoritative.
  • Deduplicate on event_id. Same id = same event, even if the payload differs between attempts.
  • Idempotent reception. Processing the same event_id twice 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 / CodeType / LimiteDescription
created → agent_approvedinstantAuto-approval applies in the same create request.
agent_approved → submitted_to_bazik0–60sThe executor picks up the queue; the Phase B reconciler covers delays.
submitted_to_bazik → completed0–10 minWhen the Bazik webhook fires correctly (common case).
submitted_to_bazik → completed (long)10 min – several hoursWhen 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_manual if 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_reference if 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 / CodeType / LimiteDescription
external_payout_stuck_pre_bazikauditRow stayed at agent_approved beyond the wait window. The Phase B reconciler auto-submits.
external_payout_stuck_in_sendingauditStuck in the send-to-Bazik step longer than expected.
external_payout_stuck_confirmationauditAt submitted_to_bazik longer than expected, with a Bazik withdrawal_id already issued.
external_payout_unconfirmed_too_longauditAt 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 / CodeType / LimiteDescription
invalid_request400Malformed body, missing canonical field, value out of range. error_description explains.
amount_below_minimum400amount_htg < 1 000.
invalid_token401Bearer missing, token unknown, revoked, or expired.
insufficient_scope403The token lacks the required scope (payout.request, payout.status.read).
connection_revoked403The user revoked the connection on the MCC side after the token was issued.
connection_not_found404The token's user_connected_app_id no longer exists.
partner_daily_cap_exceeded409Your application's per-user daily cap exceeded. DO NOT retry with the same external_reference.
plan_daily_cap_exceeded409The user's plan cap exceeded.
rate_limit_exceeded429Per-IP (240/min), per-app (600/min), or per-connection (30/min).
internal_error500MCC bug. Safe to retry with the same external_reference.
auth_unavailable503Transient infra. Safe to retry.
rate_limit_unavailable503Transient infra. Safe to retry.
{
  "error":             "insufficient_scope",
  "error_description": "Token lacks 'payout.request' scope"
}