Subscriptions API in Morocco: clients, subscriptions, webhooks and sandbox tests
Subscriptions API in Morocco: create clients and subscriptions, one payment link per due date, webhooks, pause, resume, cancel and sandbox tests, step by step.
ChariPay's subscriptions API comes down to two create calls and three events: you create a client (POST /v1/clients), then a subscription for that client (POST /v1/subscriptions); at each due date, ChariPay generates a single-use payment link and emails it to your client, who pays it on the hosted page, by card with 3-D Secure; each paid installment reaches you through a signed webhook. This guide walks through every step with calls checked against the API contract: required and optional fields, the first payment link, reminders, subscription.* events, lifecycle management, idempotency, errors and a sandbox test plan.
The subscriptions API: the model
ChariPay is a Moroccan payment platform, a PSP operated by Chari Money, a payment institution licensed by Bank Al-Maghrib. Its subscriptions module works with three objects:
| Object | Main endpoint | What it carries |
|---|---|---|
| Client | POST /v1/clients | Name, email — the address the payment links are sent to —, phone |
| Subscription | POST /v1/subscriptions | Amount, frequency, dates, status, metadata |
| Installments | GET /v1/subscriptions/{reference}/charges | Every installment generated — one single-use payment link per period, with its status and payUrl — most recent first |
The principle fits in one sentence: a ChariPay subscription is a series of payment links. On the first due date (startDate), then every period according to the frequency, ChariPay creates a single-use payment link for the period and emails it to your client, with the subject "Nouvelle échéance" ("New installment") followed by the subscription's description. Your client opens the link and pays on the hosted page, by card with 3-D Secure. As long as the installment is unpaid, a reminder goes out by email every day ("Rappel de paiement", "Payment reminder") with the same link, and no new period is opened: unpaid installments do not pile up.
A subscription's status follows a simple cycle. On the API side, these are the values of the status enum, shown here with their plain-word equivalent:
| API value | In plain words | Meaning |
|---|---|---|
INCOMPLETE | Not active yet | Created; the first installment is not paid yet |
ACTIVE | Active | First installment paid (activatedAt set); a link goes out at each due date |
PAUSED | Paused | No new link is generated until it is resumed |
COMPLETED | Completed | End date reached and last installment paid |
CANCELED | Canceled | Canceled by you, or an installment left unpaid past the 14-day window |
The conventions are those of the whole API: a single base, https://api-psp.charipay.ma, in sandbox and production alike — the key picks the environment (chari_sk_test_… or chari_sk_live_…); the X-CHARI-PAY-API-KEY header on every request; JSON in camelCase; amounts in MAD, in major units (149.90 = MAD 149.90); optional fields absent, never null; upper-case enums, whose new values your code must tolerate. The API overview is in payment API in Morocco, and the business-side offer on the subscriptions and recurring payments page.
To run the calls below, you need a chari_sk_test_… key. Test mode opens as soon as your account is activated, with no approval to wait for: 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: only this card is accepted, and no real money moves. Your business verification (KYB) runs in parallel and only gates the move to production.
Step 1: create the client
A subscription always belongs to an existing client. You create the client once and reuse it: for a second subscription, for a plan change.
curl -X POST 'https://api-psp.charipay.ma/v1/clients' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
-H 'Content-Type: application/json' \
-d '{
"name": "Amine Bennani",
"email": "amine.bennani@example.com",
"phone": "+212600000000"
}'Only name is required by the client schema. Still, always provide email when the client will be subscribed: that is where the payment link for each installment and the reminders are sent. The phone is optional and follows the E.164 format. The 201 response contains the client's id, a UUID: store it in your database next to your own customer identifier.
The module has five calls: create, list (GET /v1/clients), retrieve (GET /v1/clients/{id}), update (PUT /v1/clients/{id}), handy for fixing an email address before the next due date, and delete (DELETE /v1/clients/{id}), refused as long as the client still has active subscriptions. Field details are in the Clients module of the API docs.
Step 2: create the subscription
The subscription carries the plan: amount, frequency, dates, and whatever you will need to recognize it in your webhooks. The contract has nine fields, no more:
| Field | Required | Format and role |
|---|---|---|
clientId | Yes | UUID of the client created in step 1 |
amount | Yes | Amount of each installment, in MAD, major units |
description | Yes | Label shown on every payment link, 255 characters at most |
frequency | Yes | DAILY, WEEKLY, MONTHLY or YEARLY |
startDate | Yes | Date of the first installment, in YYYY-MM-DD format |
channels | Yes | Channel used to send the payment links: ["EMAIL"] |
endDate | No | End date; without it, the subscription is open-ended |
externalId | No | Your subscription identifier, unique per account and per environment; it makes the create replayable |
metadata | No | Flat object, 4 KB at most, carried on every payment link and in every subscription webhook |
curl -X POST 'https://api-psp.charipay.ma/v1/subscriptions' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
-H 'Content-Type: application/json' \
-d '{
"clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": 149.90,
"description": "Gold plan - monthly",
"frequency": "MONTHLY",
"startDate": "2026-11-01",
"channels": ["EMAIL"],
"externalId": "sub-cus-1001-gold",
"metadata": { "customerId": "cus_1001", "plan": "gold" }
}'The 201 response — or 200 if the externalId already exists — describes the subscription: its reference (sub_…), used by every later call, its status — INCOMPLETE for a newly created subscription —, its origin (API, or DASHBOARD for a subscription created from the portal), its next installment (nextRunDate, the date the next period opens and its link is emailed) and, when a period is open, currentCharge, the payment link for the current period. Dates are on the Africa/Casablanca time zone. In metadata, put opaque identifiers — your customer number, your plan code — never card data or unnecessary personal data. The full schema is in the Subscriptions module of the API docs.
Step 3: the first payment link and reminders
A newly created subscription stays INCOMPLETE until its first installment is paid. That first installment is a payment link like all the others: your client receives it by email and pays it on the hosted page, by card with 3-D Secure. Your server never sees any card data.
The API contract describes how this first installment shows up:
- If `startDate` is today or in the past, the first link is created immediately and returned in
currentCharge, a payment link object that carries the buyer-facingpayUrl: you can send your client there as soon as they finish signing up, without waiting for them to open the email. - If `startDate` is in the future, no link is created yet: the first one goes out on that date. Read the subscription again with
GET /v1/subscriptions/{reference}; once the period is open, itscurrentChargecarries the current installment.
Once the first payment succeeds, the status switches to ACTIVE and the activatedAt field tells you when. The same sequence then repeats at every due date: a new link, an email, a payment on the hosted page, a webhook.
When an installment is not paid, your client receives a reminder by email every day, with the same link. No new period is opened in the meantime: they only ever have one installment to settle, never a growing debt. The link stays payable for 14 days after the due date — or after it was issued, if that is later; if it expires unpaid, the subscription moves to CANCELED and you receive subscription.canceled. How to handle these late payments on the business side is described in failed subscription payments and reminders.
Step 4: listen to subscription.* events
Three events track a subscription's life:
| Event | Meaning | Typical action |
|---|---|---|
subscription.payment_succeeded | An installment is paid | Extend access, prepare the shipment, mark as paid |
subscription.payment_failed | A card payment attempt on an installment's link failed; the link stays payable | Record the failure, invite the customer to try again on the same link |
subscription.canceled | The subscription was canceled because an installment stayed unpaid for more than 14 days | Cut the service according to your terms |
Declare an endpoint explicitly subscribed to these three events:
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": "Subscriptions",
"enabledEvents": ["subscription.payment_succeeded", "subscription.payment_failed", "subscription.canceled"]
}'The response contains signingSecret (whsec_…), shown only once: keep it in a vault. On the receiving side, route on the Chari-Event-Type header, and deduplicate on Chari-Event-Id, not on Chari-Webhook-Id — the latter identifies the delivery, not the event.
For subscriptions, one exception to the general rule matters: every subscription.* event carries your subscription externalId (ExternalId), the date of the period concerned (PeriodDate) and your metadata, echoed unchanged — whereas the externalId of a session or a link does not come back. A minimal receiver, with the verify signature-checking function:
app.post('/webhooks/charipay', express.raw({ type: '*/*' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(raw, req.get('X-CHARI-SIGNATURE'), req.get('X-CHARI-TIMESTAMP'), secret)) {
return res.status(401).end();
}
const eventId = req.get('Chari-Event-Id');
if (await db.events.exists(eventId)) return res.status(200).end();
const type = req.get('Chari-Event-Type');
const event = JSON.parse(raw);
if (type === 'subscription.payment_succeeded') {
await members.markPaid(event.metadata.customerId); // your metadata, echoed verbatim
} else if (type === 'subscription.payment_failed') {
await members.noteFailedAttempt(event.metadata.customerId); // the link stays payable
} else if (type === 'subscription.canceled') {
await members.endAccess(event.metadata.customerId);
}
await db.events.insert({ eventId });
return res.status(200).end();
});When you cancel yourself, the POST …/cancel response already carries the CANCELED status: update your database at that point, without waiting for an event. If in doubt about a subscription's state, read it again with GET /v1/subscriptions/{reference}. Signatures, replay protection and delivery retries are covered in getting your webhook integration right.
Managing the lifecycle: pause, resume, cancel, installments
The portal's actions on a subscription — pause, resume, cancel, viewing installments — are also available through the API. Together with the create call, these are the module's seven endpoints:
| Call | Effect |
|---|---|
GET /v1/subscriptions | Lists your subscriptions, page by page |
GET /v1/subscriptions/{reference} | Retrieves a subscription, with its status and the current period's link (currentCharge) |
GET /v1/subscriptions/{reference}/charges | Lists the generated installments — one link per period, with its status and payUrl — most recent first |
POST /v1/subscriptions/{reference}/pause | Suspends the subscription: no new link is generated until it is resumed |
POST /v1/subscriptions/{reference}/resume | Resumes the cycle; periods that elapsed during the pause are not billed retroactively |
POST /v1/subscriptions/{reference}/cancel | Ends the subscription permanently; no further installment is generated |
curl -X POST 'https://api-psp.charipay.ma/v1/subscriptions/sub_7Qa1bx/pause' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...'Three design rules follow. A plan change is a cancel followed by a new POST /v1/subscriptions for the same client. There is no proration and no trial period: a delayed start, through startDate, does the job, and no link goes out before the first due date. Finally, GET …/charges is your reference for reconciliation: every installment appears there with its status and its link, which lets you send a client the link of an installment that is still open.
Idempotency and useful errors
For a subscription create, the replay key is the externalId: unique per account and per environment, it makes a second create with the same value return the existing subscription with 200 OK instead of 201 Created. Both are successes: check the status code, not just the body. Derive the externalId from your business object — your customer identifier and plan code, for example — so that a network retry or a double submission never creates two subscriptions. The contract declares no Idempotency-Key header for a subscription create: the externalId plays that role.
All /v1 errors share 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 (an X-Request-Id you send becomes the correlationId). The codes you will meet most:
| Status | Code | Cause | What to do |
|---|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid field (frequency, date, a channel other than EMAIL, a description over 255 characters) | Fix the request; do not replay it as is |
| 401 | UNAUTHORIZED | Missing or invalid key | Check the X-CHARI-PAY-API-KEY header |
| 403 | PRODUCTION_ACCESS_NOT_ENABLED | chari_sk_live_… key before production is enabled | Stay in sandbox until activation |
| 422 | WALLET_NOT_ACTIVE | Valid request, but the wallet involved is not active | Do not retry in a loop; contact the team, with the correlationId |
| 429 | RATE_LIMITED | Too many requests | Wait for Retry-After, then replay |
5xx errors are on our side: replay with your idempotency key — for a subscription create, the same externalId.
Testing installments in the sandbox
Waiting a month to check a second installment is not a test plan. The method comes down to three settings, with no special call: today's startDate, so the first link is created immediately; the test card, to pay it; and the DAILY frequency, so a second installment arrives the very next day.
curl -X POST 'https://api-psp.charipay.ma/v1/subscriptions' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
-H 'Content-Type: application/json' \
-d '{
"clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": 10.00,
"description": "Test plan - daily",
"frequency": "DAILY",
"startDate": "2026-10-05",
"channels": ["EMAIL"],
"externalId": "test-sub-001"
}'Replace startDate with today's date, on the Africa/Casablanca time zone. The response carries the first link in currentCharge: open its payUrl and pay with the test card — 4918 9141 0719 5005, CVV 123, any future expiry date, 3-D Secure code 555. The subscription switches to ACTIVE, and subscription.payment_succeeded reaches your sandbox endpoint. The next day, the second installment is open: currentCharge carries a new link, and GET …/charges lists two. Webhooks go out as in production.
A complete test plan fits in eight steps, over three days:
- 1Create the client, then a
DAILYsubscription with today'sstartDate: statusINCOMPLETE,currentChargepresent with itspayUrl. - 2Open the
payUrland pay with the test card, 3-D Secure code 555: statusACTIVE,activatedAtset,subscription.payment_succeededevent received. - 3The next day, read the subscription again: a new
currentChargeis open andnextRunDatehas moved forward. Pay that second link: a new event arrives. - 4The day after, leave the third installment unpaid: no new period opens, and your database should show it as pending.
- 5Check that your database is up to date and that an event received twice is processed only once.
- 6Pause, then resume: statuses
PAUSED, thenACTIVE, with no link generated during the pause. - 7Cancel: the response carries the
CANCELEDstatus, and your database reflects it. - 8List the installments (
GET …/charges) and reconcile them with your database.
Only the move to CANCELED after 14 days without payment takes waiting: leave a test subscription unpaid and check how you handle subscription.canceled two weeks later. The rest of the test plan — the card, payment scenarios, what cannot be tested — is in testing payments in test mode.
Frequently asked questions
Which fields are required to create a subscription through the API?
Six fields: clientId (the UUID of an existing client), amount in MAD, description, frequency (DAILY, WEEKLY, MONTHLY or YEARLY), startDate in YYYY-MM-DD format, and channels, set to ["EMAIL"]. The client must also have a valid email address, since that is where the payment link for each installment is sent. Three fields are optional: endDate, externalId and metadata.
Why does my subscription stay in the INCOMPLETE status?
Because its first installment is not paid yet. The subscription stays INCOMPLETE until your client has paid the first payment link, on the hosted page, by card with 3-D Secure. Read it with GET /v1/subscriptions/{reference}: currentCharge carries the open installment and its payUrl, which you can send to your client again; if the first due date is in the future, no link has been created yet.
Is the client's email mandatory for a subscription?
Yes, in practice. The client schema only requires name, but the payment link for each installment and the reminders go out by email: without a valid address, your client would receive nothing. So fill in email from POST /v1/clients, or add it later with PUT /v1/clients/{id}, before you create the subscription.
What happens if my client does not pay an installment?
They receive a reminder by email every day, with the same link, and no new period is opened until the installment is settled. The link stays payable for 14 days after the due date; if it expires unpaid, the subscription moves to CANCELED and you receive subscription.canceled. A declined card payment attempt on the link reaches you as subscription.payment_failed, and the link stays payable.
Does my externalId come back in subscription webhooks?
Yes. It is an exception to the general rule: the externalId of a session or a link does not come back in events, but a subscription's does, in every subscription.* event, along with the period date (PeriodDate) and your metadata echoed unchanged. You can therefore tie each installment to your customer record without an extra mapping table.
What happens if I replay the create with the same externalId?
The API does not create a duplicate: it returns the existing subscription with 200 OK instead of 201 Created. Treat both as successes and read the returned reference. The externalId is unique per account and per environment: the same value can exist once in the sandbox and once in production.
Which billing frequencies does the subscriptions API accept?
Four values for frequency: DAILY, WEEKLY, MONTHLY and YEARLY. The amount is fixed for the subscription's whole life; to change plan or frequency, cancel the subscription and create a new one for the same client. An optional end date (endDate) caps the duration.
How do I test later installments without waiting?
In the sandbox, create a DAILY subscription with today's startDate: the first link is created immediately and returned in currentCharge. Pay it with the test card, 3-D Secure code 555: the subscription switches to ACTIVE and subscription.payment_succeeded arrives. The next day, a second installment opens, with a new link to pay the same way.
Next step
Run the four steps in the sandbox with a DAILY subscription that starts today, pay two installments with the test card and check your webhooks: Start in test mode. Once your test plan is green, prepare the switch with moving from sandbox to production: only the key and the webhook secret change.
Written by ChariPay team.