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.
payout-create API, documented in the merchant docs (no team activation required, as soon as KYC is approved).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 network ceiling |
| MonCash execution | MCC | Via the MCC executor, then asynchronous delivery confirmation. |
| 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://api.moncashconnect.com/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://api.moncashconnect.com/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://api.moncashconnect.com/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 — asynchronous delivery confirmation
submitting 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. |
| submitting | non-terminal | The settlement network accepted the submission. Delivery confirmation pending — can take hours. |
| completed | terminal | Delivery confirmed. The confirmation source is not exposed on the wire. |
| failed | terminal | The executor or the settlement network 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.
Timestamp of completed
The webhook's occurred_at reflects when MCC marked the row as completed, not necessarily when the actual MonCash delivery happened. When confirmation is delayed, the two can be several hours apart. Treat completed as authoritative for state, not as a to-the-second delivery timestamp.
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 /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 | The settlement network accepted the submission; awaiting delivery confirmation. (data.status = 'submitting') |
| 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 submission to the settlement network. Funds refunded. |
| delivery_failed | network/MonCash | The settlement network 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 → submitting | 0–60s | The executor picks up the queue; the Phase B reconciler covers delays. |
| submitting → completed | 0–10 min | When delivery confirmation arrives quickly (common case). |
| submitting → completed (long) | 10 min – several hours | When delivery confirmation is delayed; resolved by reconciliation. |
What to do if a payout stays at submitting ≥ 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. Support will verify independently and confirm the delivery if it did occur.
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"
}