Sub-merchant API in Morocco: crediting a sub-merchant's wallet and tracking its webhooks
Sub-merchant API in Morocco: create a session that credits a wallet with config.settlement, track wallet.activated and payout events, and handle errors.
With the sub-merchant API in Morocco, collecting on behalf of a sub-merchant takes an ordinary checkout session: create it as you would for any payment and add a settlement object inside config, naming the wallet to credit — by its walletId or by the sub-merchant's subMerchantReference. The buyer pays on the hosted checkout, and your system follows the rest through signed webhooks: wallet.activated and wallet.rejected for verification, payout.completed and payout.failed for payouts, submerchant.near_cap for balance caps. This guide covers the request, the events, declaring the endpoint, the errors to expect, what happens in the portal versus the API, and reconciliation per sub-merchant.
Sub-merchant API: wallet, walletId and subMerchantReference
ChariPay is a Moroccan payment platform, a PSP operated by Chari Money, a payment institution licensed by Bank Al-Maghrib. Its distribution space is for aggregators, franchises and agent networks. A sub-merchant is an entity you create in your distribution space, a seller or a store for example; it is verified like any merchant — documents and trade register — before collecting in production, and has its own wallet.
On the API side, four notions are enough:
| Term | What it is | Format |
|---|---|---|
| Sub-merchant | Entity created in your distribution space, verified like any merchant | Declared from the portal or through the API |
| Wallet | The sub-merchant's own wallet, credited by the sessions that name it | One per sub-merchant |
walletId | ID of the sub-merchant wallet to credit | UUID, for example 3fa85f64-5717-4562-b3fc-2c963f66afa6 |
subMerchantReference | Reference of the sub-merchant to credit | String, for example subm_9Zt2Bc |
Both identifiers go into config.settlement, an optional object on the session: send one or the other, whichever your database already stores. That object's only purpose is to name a sub-merchant wallet to credit.
A sub-merchant is declared from the portal or through the API; the public API contract does not document that call, which is set up with the integration team. The offer is described on the distribution and sub-merchants page, and the business model of a multi-seller platform in marketplace payments.
Creating a session that credits a sub-merchant
The request is that of a regular checkout session, with an extra settlement block inside config:
curl -X POST 'https://api-psp.charipay.ma/v1/payment-sessions' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-2026-0421-session' \
-d '{
"amount": 250.00,
"orderId": "ORD-2026-0421",
"externalId": "order-2026-0421",
"config": {
"customer": {
"email": "amine.bennani@example.com",
"firstName": "Amine",
"lastName": "Bennani",
"phone": "+212600000000"
},
"urls": {
"accept": "https://votre-boutique.ma/paiement/succes",
"decline": "https://votre-boutique.ma/paiement/echec",
"notification": "https://votre-boutique.ma/webhooks/charipay"
},
"settlement": {
"subMerchantReference": "subm_9Zt2Bc"
}
},
"metadata": { "cartId": "c_987", "source": "web" }
}'The response (201, or 200 on a replay of the same externalId) contains these five fields and nothing else:
{
"sessionId": "ps_5Kd0Rn",
"sessionToken": "st_…",
"verifyKey": "vk_5Kd0Rn",
"expiresAt": "2026-10-05T10:15:00Z",
"checkoutUrl": "https://…/checkout/ps_5Kd0Rn"
}Redirect the buyer to checkoutUrl: they pay by card with 3-D Secure on the hosted page. The response returns no status, no amount and no sub-merchant: status and amount are read with GET /v1/payment-sessions/{sessionId}, and it is up to your database to keep the link between sessionId and sub-merchant. Each session has a limited lifetime — 72 hours by default, expiresAt to shorten it — and is consumed only once. Confirm the order when the webhook arrives, never on the redirect alone. Fields and responses are detailed in the Checkout sessions module of the API docs.
You can write this first call today: Start in test mode. Three fields — name, work email, company — an activation link received by email to choose your password, then the portal, where you create your own test key after re-authenticating; the full key is shown only once. Pay with the test card 4918 9141 0719 5005, CVV 123, any future expiry date, 3-D Secure code 555. You can create checkout sessions right away; the distribution setup — and therefore the sub-merchants that config.settlement names — is prepared with the integration team.
Sub-merchant lifecycle events
Five webhook events concern distribution. Their name says what they signal; how you react is up to you:
| Event | What it signals | Suggested reaction |
|---|---|---|
wallet.activated | A sub-merchant's wallet is activated after verification | Mark the sub-merchant "active" in your database and open its sales |
wallet.rejected | A sub-merchant's wallet is rejected at verification | Block its sales, notify it, revisit its application |
payout.completed | A payout has been executed | Mark the payout as executed in your accounting |
payout.failed | A payout has failed | Alert the finance team and investigate before any new request |
submerchant.near_cap | A sub-merchant is approaching its balance cap | Warn the store manager or the finance team |
Payments themselves arrive as payment.succeeded. Always route on the Chari-Event-Type header, and read the event ID from Chari-Event-Id. Deduplicate on Chari-Event-Id, not on Chari-Webhook-Id — the latter identifies the delivery, not the event. Verify the X-CHARI-SIGNATURE signature with X-CHARI-TIMESTAMP before any processing, and build nothing on body fields beyond your own metadata. The catalog and delivery rules are in the Webhooks module of the API docs.
Declaring the endpoint that receives these events
A webhook endpoint is declared with POST /api/v1/partner/webhooks/endpoints, using the key of the target environment. Subscribe it to an explicit list of events: an empty list means a subscription to every event, current and future.
curl -X POST 'https://api-psp.charipay.ma/api/v1/partner/webhooks/endpoints' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://votre-boutique.ma/webhooks/charipay",
"description": "Paiements boutique",
"enabledEvents": [
"payment.succeeded",
"wallet.activated",
"wallet.rejected",
"payout.completed",
"payout.failed",
"submerchant.near_cap"
]
}'Three rules to know:
- the
urlmust be public HTTPS on port 443:localhost, private addresses and plain HTTP are rejected at registration — in development, go through a tunnel; - the response carries the
signingSecret(whsec_…) only once, at creation or rotation: store it in a vault immediately; - an endpoint belongs to one environment, and secrets differ between sandbox and production.
The same declaration can be made from the portal. For local debugging — tunnel, test event, delivery log — see testing your webhooks locally.
Errors to plan for
Every /v1 API error shares the same envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "amount: must be greater than 0"
},
"correlationId": "b0c1e2d3-4f56-7890-abcd-ef0123456789"
}Test the code, never the message, and log the correlationId. The codes that matter for this flow:
| Status | Code | What it means | What to do |
|---|---|---|---|
| 400 | VALIDATION_ERROR | The request is malformed or failed validation | Fix the request before sending it again; do not replay it as is |
| 401 | UNAUTHORIZED | Missing or invalid API key | Check the X-CHARI-PAY-API-KEY header and the key's environment |
| 403 | PRODUCTION_ACCESS_NOT_ENABLED | A chari_sk_live_… key used before production is enabled | Stay in test mode until activation |
| 409 | IDEMPOTENCY_CONFLICT | Same Idempotency-Key, different body | Generate a new key for a new request |
| 422 | WALLET_NOT_ACTIVE | Valid request, but a business rule blocks it | Do not redirect the buyer; log it and check with the integration team if it persists |
The contract gives WALLET_NOT_ACTIVE as a generic example of a 422 code on most of its endpoints: do not read a more specific cause into it than its name. Remember too that a replay on an existing externalId returns 200 OK instead of 201 Created: both are successes, so test the status.
What happens in the portal, what happens through the API
| Operation | Where it happens | What your system sees |
|---|---|---|
| Declare a sub-merchant, one by one or in bulk | Portal or API | The sub-merchant and its own wallet |
| Verify the sub-merchant | Built in: documents and trade register | wallet.activated or wallet.rejected |
| Collect for a sub-merchant | API: session with config.settlement | payment.succeeded, with your metadata |
| Receive events | Endpoint declared through the API or from the portal | Chari-Event-Type and Chari-Event-Id headers |
| Pay out | Payouts under dual approval: distinct requester and approver | payout.completed or payout.failed |
| Cap the balance | Balance caps defined at setup | submerchant.near_cap |
Everything else — allocation, automatic collection frequency, how approvers are organized — is prepared with the integration team. Keep the boundary in mind: for distribution, the public API contract covers the session with config.settlement and the events; the rest follows the governance set at setup.
Reconciling collections per sub-merchant
The creation response does not name the sub-merchant, and no undocumented field in the event body should be used to find it. So make each payment self-describing: add the sub-merchant reference to your metadata — for example "subMerchantReference": "subm_9Zt2Bc" next to cartId. Your metadata is echoed verbatim in the payment event, and a session's event also carries your orderId. Find your order through these two values: a session's externalId does not come back in the event.
Two design rules are enough:
- 1When you create the session, store the
sessionId→ sub-merchant → order row in your database. - 2When
payment.succeededarrives, find that row through yourmetadataand mark the order paid.
After your server has been unavailable, reconcile with GET /v1/transactions: polling is a fallback, not the source of truth. A monthly statement is also produced for each sub-merchant, covering only its own activity.
Going live with a sub-merchant network
Once KYB is validated — identity documents and trade register, with dual human review — your application is reviewed and you receive a priced proposal, then your account is activated and your production key created. In your code, you change only the key and the webhook secret: URLs, payloads and error codes are identical. Until production is enabled, a chari_sk_live_… key receives 403 PRODUCTION_ACCESS_NOT_ENABLED. Keep the sandbox as your staging environment, with two sets of environment variables.
For distribution, the setup — sub-merchants, caps, collection, approvers — is prepared with the integration team; we make no promise here about how it behaves in the sandbox. For the rest of the API — payment links, refunds, transactions — see payment API in Morocco.
Frequently asked questions
What is the difference between walletId and subMerchantReference?
Both name the destination of a payment in config.settlement. walletId is the ID of the sub-merchant's wallet, in UUID format; subMerchantReference is the sub-merchant's reference, for example subm_9Zt2Bc. Send one or the other, whichever your database already stores. The settlement block is optional: its only purpose is to name a sub-merchant wallet. Since both fields serve the same purpose, pick one and stick to it across all your calls: your reconciliation will be simpler for it.
Can a sub-merchant be declared through the API?
Yes: declaration happens from the portal or through the API, one by one or in bulk. The public API contract does not document that call; it is set up with the integration team when the distribution setup is put in place. Either way, the sub-merchant is verified like any merchant — documents and trade register — before collecting in production.
What should I do if session creation returns a 422 error?
Do not redirect the buyer. A 422 means the request is valid but a business rule blocks it; the contract cites WALLET_NOT_ACTIVE as an example, and its name says no more than that. Read the code, log the correlationId, check the status of the named sub-merchant in your database, and check with the integration team if the code persists.
Which webhooks should I follow for a sub-merchant network?
payment.succeeded for payments, then the five distribution events: wallet.activated and wallet.rejected for verification, payout.completed and payout.failed for payouts, submerchant.near_cap for caps. Subscribe your endpoint to this explicit list rather than to every event, route on Chari-Event-Type and deduplicate on Chari-Event-Id. Verify the signature before any processing, and build nothing on body fields beyond your own metadata.
How do I know a sub-merchant has been approved?
Through the wallet.activated event, which signals that its wallet has been activated after verification; wallet.rejected signals a rejection instead. We publish no verification timeline. Wire these two events to the sub-merchant's status in your database, and only open its sales in production after activation. Until then, keep the sub-merchant in a "pending verification" status on your platform.
Does the session creation response show which sub-merchant is credited?
No. The response contains five fields and nothing else: sessionId, sessionToken, verifyKey, expiresAt and checkoutUrl. Status and amount are then read with GET /v1/payment-sessions/{sessionId}. Keep the link between sessionId and sub-merchant yourself, and add its reference to your metadata, which is echoed verbatim in the payment event: you will then find the sub-merchant in every payment event, with no manual lookup.
How do I find a sub-merchant's payments?
Through your own database, fed by the events: put the sub-merchant reference in your metadata, which is echoed verbatim in the payment event, and a session's event also carries your orderId. After downtime, reconcile with GET /v1/transactions. A monthly statement is also produced for each sub-merchant, limited to its own activity.
Next step
Write your sessions and your webhook receiver against the sandbox: Start in test mode, then pay with the test card. In parallel, prepare the distribution setup with the integration team, starting from the distribution and sub-merchants page and the Checkout sessions module of the API docs.
Written by ChariPay team.