Skip to main content
All articles
Technical15 min read

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:

ObjectMain endpointWhat it carries
ClientPOST /v1/clientsName, email — the address the payment links are sent to —, phone
SubscriptionPOST /v1/subscriptionsAmount, frequency, dates, status, metadata
InstallmentsGET /v1/subscriptions/{reference}/chargesEvery 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 valueIn plain wordsMeaning
INCOMPLETENot active yetCreated; the first installment is not paid yet
ACTIVEActiveFirst installment paid (activatedAt set); a link goes out at each due date
PAUSEDPausedNo new link is generated until it is resumed
COMPLETEDCompletedEnd date reached and last installment paid
CANCELEDCanceledCanceled 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.

bash
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:

FieldRequiredFormat and role
clientIdYesUUID of the client created in step 1
amountYesAmount of each installment, in MAD, major units
descriptionYesLabel shown on every payment link, 255 characters at most
frequencyYesDAILY, WEEKLY, MONTHLY or YEARLY
startDateYesDate of the first installment, in YYYY-MM-DD format
channelsYesChannel used to send the payment links: ["EMAIL"]
endDateNoEnd date; without it, the subscription is open-ended
externalIdNoYour subscription identifier, unique per account and per environment; it makes the create replayable
metadataNoFlat object, 4 KB at most, carried on every payment link and in every subscription webhook
bash
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.

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-facing payUrl: 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, its currentCharge carries 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:

EventMeaningTypical action
subscription.payment_succeededAn installment is paidExtend access, prepare the shipment, mark as paid
subscription.payment_failedA card payment attempt on an installment's link failed; the link stays payableRecord the failure, invite the customer to try again on the same link
subscription.canceledThe subscription was canceled because an installment stayed unpaid for more than 14 daysCut the service according to your terms

Declare an endpoint explicitly subscribed to these three events:

bash
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:

javascript
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:

CallEffect
GET /v1/subscriptionsLists 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}/chargesLists the generated installments — one link per period, with its status and payUrl — most recent first
POST /v1/subscriptions/{reference}/pauseSuspends the subscription: no new link is generated until it is resumed
POST /v1/subscriptions/{reference}/resumeResumes the cycle; periods that elapsed during the pause are not billed retroactively
POST /v1/subscriptions/{reference}/cancelEnds the subscription permanently; no further installment is generated
bash
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:

json
{
  "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:

StatusCodeCauseWhat to do
400VALIDATION_ERRORMissing or invalid field (frequency, date, a channel other than EMAIL, a description over 255 characters)Fix the request; do not replay it as is
401UNAUTHORIZEDMissing or invalid keyCheck the X-CHARI-PAY-API-KEY header
403PRODUCTION_ACCESS_NOT_ENABLEDchari_sk_live_… key before production is enabledStay in sandbox until activation
422WALLET_NOT_ACTIVEValid request, but the wallet involved is not activeDo not retry in a loop; contact the team, with the correlationId
429RATE_LIMITEDToo many requestsWait 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.

bash
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:

  1. 1Create the client, then a DAILY subscription with today's startDate: status INCOMPLETE, currentCharge present with its payUrl.
  2. 2Open the payUrl and pay with the test card, 3-D Secure code 555: status ACTIVE, activatedAt set, subscription.payment_succeeded event received.
  3. 3The next day, read the subscription again: a new currentCharge is open and nextRunDate has moved forward. Pay that second link: a new event arrives.
  4. 4The day after, leave the third installment unpaid: no new period opens, and your database should show it as pending.
  5. 5Check that your database is up to date and that an event received twice is processed only once.
  6. 6Pause, then resume: statuses PAUSED, then ACTIVE, with no link generated during the pause.
  7. 7Cancel: the response carries the CANCELED status, and your database reflects it.
  8. 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.

Read next

Ready to try it yourself?

Create your account and take test-mode payments today: it is free, with no approval to wait for. A question? Our team supports merchants and developers alike.

  • Free, no commitment
  • Test mode from sign-up
  • No approval to wait for