Skip to main content
API documentation

API documentation

Take payments by card or in cash, manage customers, subscriptions and refunds from your own system. Every module explained, every endpoint detailed, everything testable in the sandbox.

modules
12modules
endpoints
62endpoints
documented objects
48documented objects
API version
v1.0.0API version

Reference generated from the OpenAPI specification published by the API — it cannot drift from the contract.

Environments

One base URL — the same for testing and production. Your key selects the environment: a test key cannot touch production, and the reverse is just as true. You never switch URLs between trying things out and going live — you switch keys.

https://api-psp.charipay.ma

Sandboxchari_sk_test_…

No real money moves. The test card is accepted, webhooks fire normally, and the endpoint that forces a subscription's next due date exists only here.

Productionchari_sk_live_…

Real money. Access must be enabled on your account: until it is, the API answers 403 with code PRODUCTION_ACCESS_NOT_ENABLED.

Sandbox test card

The sandbox runs on real rails against a test environment: the flows are genuine, the money is not. Only one card is accepted there.

Only this PAN is accepted. Any other number — including the 4242… cards from other platforms — is rejected upstream, usually with a 502 and the stable BAAS_CHARI_ERROR code. If you hit that error while testing, check the card number you entered first.

What the API lets you do

In plain English

Everything you do in the ChariPay portal — collect, refund, follow the money — your software can do on its own, through the API. This page tells you where to start depending on your product.

You don't need to integrate everything: pick the door that matches how you sell — the rest of the API can come later, when your product calls for it.

The payment link

Who it's for

You sell without a website — on WhatsApp, by phone, on a quote or at the counter.

Technically

POST /v1/payment-links returns a hosted payment URL to share, QR code and printable poster included. One call is enough.

The checkout session

Who it's for

You run an e-commerce site and want payment inside the order flow.

Technically

An order becomes a session: redirect the buyer to the hosted checkout and confirm through the webhook. No card data on your side.

Direct checkout

Who it's for

You want to design your own payment screen, field by field.

Technically

Verify → submit → 3-D Secure → return: you drive every step. In exchange, card compliance enters your scope.

Getting started — from nothing to your first call

In plain English

Create a free test account and make your first call in minutes — no meeting, no commitment, no bank card.

You need nothing to begin: no existing account, no email to support, no sales meeting. Eight steps separate a blank page from an authenticated sandbox call.

1. Sign up. POST /api/public/sandbox-signup is public. You declare your work email; we send you an activation link.

2. Activate your account. The link carries an activation token, which you pass to POST /api/public/sandbox-signup/set-password along with the password you choose. That token is never returned by an API response: it exists only in the email.

3. Log in to the portal. POST /api/v1/auth/login checks your credentials. Depending on your account's permissions, the portal may first have you enrol a TOTP app (QR code, then a six-digit code at every login) — keep your recovery codes safe.

4. Select your company. POST /api/v1/auth/select-company confirms which company you are working on: it is this response — not the login — that carries your session token (JWT).

5. Clear the step-up. Creating an API key is a sensitive action: POST /api/v1/auth/step-up issues a short-lived confirmation token, required at step 7.

6. Look at the available permissions. GET /api/v1/api-keys/available-permissions lists the scopes your account may grant a key. Grant only what your integration actually calls.

7. Create your key. POST /api/v1/api-keys mints it with the scopes you chose. The full key is shown exactly once, at that moment — put it straight into a vault or an environment variable.

8. Check that everything holds. GET /v1/wallet with your new key: if the balance comes back, your integration is authenticated and you can start.

Note that steps 1 to 3 are unauthenticated, steps 4 to 7 belong to the portal session, and step 8 is the first to use the API key. It is the only place in the API where those three regimes meet.

One detail that saves an hour of confusion: the sign-up and portal endpoints (steps 1 to 7) have their own error format, with ERR-XXXX codes. The envelope described in the Errors guide applies to the merchant API — the one your key calls.

Authentication

In plain English

A secret key identifies your software on every call, like an access badge. Test and production are two different badges: the test key can never touch real money.

Every request carries your API key in a header. There is no token to refresh and no OAuth dance: the key is enough, and it is what determines the environment.

A sandbox key starts with chari_sk_test_, a production key with chari_sk_live_. So you do not choose the environment in the URL or in a header: you choose it by choosing the key. That is deliberate — it makes it impossible to send a test request to production by mistake.

A key is a secret. It lives on your server, in an environment variable or a vault. It must never appear in code shipped to a browser, in a mobile app, in a git repository, or in a screenshot.

The /checkout/* endpoints are the exception: they are carried by the payment session itself, protected by a single-use verification key. Never send your API key there — those are the only calls the buyer's browser executes.

Idempotency — replay without doubling

In plain English

If the network drops and your system sends the same request twice, your customer will never be charged twice. Here is the mechanism that guarantees it — and how to use it well.

A network that drops between your server and ours leaves you in doubt: did the create go through? In payments, blind retries are the best way to charge the same customer twice.

Two independent mechanisms answer two different failures. They stack, and the right instinct is to know which one protects you from what.

The Idempotency-Key header protects against network retries. You send it on your creates; replaying a call with the same value returns the first result instead of creating a duplicate. That is the protection against a timeout, a dropped connection, an HTTP client retrying on its own.

The externalId field protects against business re-issue. It is unique per account and per environment: creating a resource with an externalId that already exists returns the existing resource with 200 OK, where a genuine create answers 201 Created. That is the protection against your own system re-issuing the same intent — a job re-run, a queue replayed, a double click in a back office.

So test the status, not just the body: 201 means “I just created it”, 200 means “it already existed”. Both are successes.

Refunds use refundReference for the same reason. Derive it from the order identifier — never from randomness: a deterministic reference makes replay safe, a random one refunds twice. And mind the statuses: a fresh refund answers 202 Accepted — it executes asynchronously — and a replay 200. Never 201.

The same externalId may exist once in sandbox and once in production without conflict: its deduplication is scoped to the environment. The Idempotency-Key barrier on creation, however, is scoped to the account — so keep distinct idempotency keys between your tests and your production.

Errors

In plain English

When a call fails, the API always answers in the same shape: a stable code for your code, a readable message for a human, and an identifier to hand to support.

Every error of the merchant API (/v1) shares the same envelope: a stable code your software can test, a readable message, and a correlationId to hand to support.

Test the code, never the message. The message may be rephrased, translated or clarified; the code is part of the contract.

The correlationId is returned on every response, including successful ones. Log it systematically: it is what lets us find one precise request in our logs.

The codes you will meet most: VALIDATION_ERROR (400, a field fails validation), UNAUTHORIZED (401, key missing or invalid), FORBIDDEN (403, the key lacks a scope), PRODUCTION_ACCESS_NOT_ENABLED (403, a production key on an account not yet enabled), WALLET_NOT_ACTIVE (422, the request is valid but a business rule blocks it) and RATE_LIMITED (429, slow down and read Retry-After).

5xx responses are on us: replay with your idempotency key rather than creating a new resource.

Three conventions hold across the API: send your own X-Request-Id — it is echoed on the response and becomes the correlationId; on the publicly exposed endpoints, read X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset to pace your traffic; and any URL you declare (return, notification) must be https://, on pain of a 400. Finally, tolerate new enum values and absent optional fields — never null: that is how the API evolves without breaking you.

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount: must be greater than 0"
  },
  "correlationId": "b0c1e2d3-4f56-7890-abcd-ef0123456789"
}
StatusCodesMeaning
400INVALID_IDEMPOTENCY_KEY · MISSING_PARAMETER · VALIDATION_ERRORMalformed request or failed validation (code = VALIDATION_ERROR / MISSING_PARAMETER).
401INVALID_TOKEN · UNAUTHORIZEDMissing or invalid API key (code = UNAUTHORIZED).
403FORBIDDEN · PRODUCTION_ACCESS_DENIEDAuthenticated but the key lacks the required permission, or production access is not enabled (code = FORBIDDEN / PRODUCTION_ACCESS_DENIED).
404OPERATION_NOT_FOUND · ORDER_NOT_FOUND · SESSION_NOT_FOUNDNo such link for this account.
409IDEMPOTENCY_CONFLICT · SESSION_ALREADY_CONSUMED · SESSION_NOT_ACTIVESession already paid or canceled (SESSION_ALREADY_CONSUMED / SESSION_NOT_ACTIVE).
410SESSION_EXPIREDSession expired (SESSION_EXPIRED).
422PAYMENT_METHOD_CONSENT_REQUIRED · WALLET_NOT_ACTIVESyntactically valid but a business rule blocks it (e.g. WALLET_NOT_ACTIVE).
429RATE_LIMITEDRate limit exceeded (code = RATE_LIMITED). Retry after the Retry-After header.

Lists and pagination

In plain English

Long lists — transactions, customers — arrive page by page. Here is how to page through them without missing anything.

List endpoints are paginated and always return the same envelope: the page contents, the page number, its size, the total number of elements and the total number of pages.

Numbering starts at zero. Never assume a list fits on one page — page through to totalPages.

On transactions, prefer cursor pagination for long traversals: ask for limit, then pass cursor = nextCursor while hasMore is true. It neither skips nor repeats a row when new transactions arrive mid-traversal.

For a large export, the dedicated transactions endpoint streams a CSV rather than a series of pages (up to 10,000 rows — beyond that, slice by period with from/to): it is faster and safer for accounting reconciliation.

Webhooks

In plain English

Instead of polling the API to know whether a payment went through, let it tell you: the moment money moves, ChariPay calls your server with a signed message. This is the single most important piece of a reliable integration.

A payment does not complete when you call it: the buyer goes through their bank, clears 3-D Secure, comes back. The result therefore reaches you by webhook, and the webhook is what to trust — not the browser redirect, which a buyer can interrupt by closing the tab. Polling is a fallback, not the source of truth.

You declare your receiving URLs from the API, with an explicit list of events. Only subscribe to what you handle: every needless event is a chance for a bug and a load on your server.

The URL must be public HTTPS, on port 443. localhost and private addresses are rejected at registration — in development, use a tunnel.

The signature. Every delivery carries X-CHARI-SIGNATURE: a lowercase hex HMAC-SHA256, computed over the string timestamp + "." + rawBody. The timestamp is in X-CHARI-TIMESTAMP, in epoch milliseconds.

Verification security comes down to four rules: compute the HMAC over the raw bytes before any parsing — a re-serialised JSON no longer produces the same signature; reject a timestamp skewed by more than ±5 minutes, as we do; compare in constant time (timingSafeEqual), never with ===; and during a secret rotation, accept either of the two signatures.

During a rotation, while the old secret is still in its grace window, we send the same body signed twice: X-CHARI-SIGNATURE with the old secret and X-CHARI-SIGNATURE-NEXT with the new one. Verify with the secret you hold, then cut over.

Deduplicate on Chari-Event-Id, not on Chari-Webhook-Id. This is the distinction that costs the most when you get it wrong: one logical event may be delivered several times, each attempt gets its own Chari-Webhook-Id — which therefore changes on every try — but they all carry the same Chari-Event-Id. Deduplicating on the wrong one means processing the same payment twice.

Delivery is at least once, and may arrive out of order: you will see repeats, by design. Only answer 2xx once both the business update and the dedup record have committed — any non-2xx is retried. Do the heavy work in the background: a handler that takes ten seconds eventually triggers redeliveries and, in time, the suspension of your endpoint. The first attempt fires immediately, then 1 min → 5 min → 30 min → 1 h → every 6 h, up to 16 attempts over roughly 72 hours; past that, the delivery is marked failed. After a longer outage, reconcile with GET /v1/transactions rather than waiting for a webhook that will not come back.

Your metadata and externalId come back on the events for the resource that carried them: that is how you reconcile without storing our references.

javascript
const crypto = require('crypto');

function verify(rawBody, signature, timestamp, secret) {
  // Fenêtre anti-rejeu de ±5 minutes — l'horodatage est en millisecondes.
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // Une signature malformée doit répondre false — jamais jeter (500 → retentatives).
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false;

  // Comparaison en temps constant : jamais `===`.
  return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}

Going live

In plain English

On launch day you change exactly one thing: the key. This list checks that everything else is ready before you switch.

The sandbox opens immediately; production is enabled on your account after your company is verified. Until then, a production key gets an explicit 403.

Before switching, a few checks are worth the time they take: your webhook handler is idempotent and verifies the signature; your creates send an idempotency key; you log the correlationId of every call; your keys live in a vault, not in your repository; and you have tested at least one refund and one failed payment, not only the happy path.

On switch-over day you change exactly one thing: the key. URLs, payloads and error codes are identical.

API modules

Twelve modules, ordered like a real integration: start with payment links or checkout, add webhooks, then the rest at your product's pace. Each module has its own page, with its endpoints, fields and examples in four languages.

7 endpoints

Payment links

An amount, a description, a link to send. The customer pays on a page hosted by ChariPay — you host no card data.

Open the module

4 endpoints

Checkout sessions

An e-commerce order becomes a session: you redirect the buyer to the hosted checkout and collect the result.

Open the module

4 endpoints

Direct checkout

The calls the payment page makes itself: session verification, card submission, 3-D Secure return.

Open the module

4 endpoints

Transactions

Everything that came in and went out: payments, refunds, payouts, fees. With the detail of an operation and its timeline.

Open the module

3 endpoints

Refunds

Refund all or part of a successful payment, with a reason and your own reference — debited from the balance and linked to the original payment.

Open the module

4 endpoints

Wallet

The balance of your payment account, its RIB in the merchant's name, the downloadable certificate, and how to fund it.

Open the module

8 endpoints

Clients

The registry of your end customers and their saved payment methods, reused for subscriptions and recurring charges without re-entering details.

Open the module

6 endpoints

Products

A simple catalogue of your products and their orders, to sell through payment links or checkout without building a full online store.

Open the module

9 endpoints

Subscriptions

A recurring charge on a saved payment method, with its due dates, pauses and dunning.

Open the module

10 endpoints

Webhooks

Your receiving URLs, their signing secrets, the list of emitted events and the detail of every delivery.

Open the module

1 endpoint

Event catalogue

The list of every event type you can subscribe to.

Open the module

2 endpoints

Customer journey

What happened between opening a link or a session and the payment: the steps taken, the drop-offs, the traffic source.

Open the module

Downloadable resources

What you need to work outside the browser: the collection to run, the contract to generate from, and the pack to hand to a coding assistant.

Glossary

The words this documentation uses in a precise sense.

API key
The secret that authenticates your calls and picks the environment. Prefix chari_sk_test_ in the sandbox, chari_sk_live_ in production.
externalId
Your own identifier, attached to a resource so you can find it from your system. Unique per account and per environment.
Idempotency-Key
Optional header on creates. Two calls carrying the same key produce a single resource.
correlationId
Identifier returned on every response. It is what support will ask for to find a call.
Payment link
A payment page hosted by ChariPay, created in one call, shareable as a link, a QR code or a poster.
Checkout session
An e-commerce order turned into a payment funnel, with return URLs that belong to you.
3-D Secure
The strong authentication demanded by the buyer's bank. It interrupts the journey, which is the whole reason webhooks exist.
Webhook
A signed notification we send to your server when an event happens. It is the source of truth for a payment.
Wallet
The merchant's payment account — held by Chari Money, a payment institution licensed by Bank Al-Maghrib — with its balance and its RIB in the merchant's name.
MAD
The Moroccan dirham. Every amount in the API is in major units — 249.00 means 249 dirhams.

Talk to an integrator

A question about integration?

Our technical team supports integrators from the first sandbox call through to go-live.