Skip to main content
All articles
Technical24 min read

Integrating a payment API in Morocco: test and live keys, links, sessions, signed webhooks, going live

Integrating a payment API in Morocco: test and live keys, payment links and checkout sessions, HMAC-signed webhooks, idempotency, test card, go-live.

Integrating a payment API in Morocco comes down to four building blocks: an API key that selects the environment, a server-side create — a payment link or a checkout session —, a signed webhook that is the source of truth, and an idempotency key so you never charge twice. This guide walks through those blocks in the order of a real integration, with code exactly as it runs against https://api-psp.charipay.ma, in curl, Node and Python. It ends with the sandbox — open the moment you sign up, free, with no time limit — and with the exact list of what changes on go-live day: the key, the webhook secret, and nothing else.

Payment APIs in Morocco: what an API backed by a licensed institution must do

In Morocco, collecting money on behalf of a third party is a regulated activity: the entity that receives the buyer's money, holds it while the payment is processed and pays it out to the merchant must be licensed by Bank Al-Maghrib. A payment API therefore never exists "on its own": behind the endpoints there is an institution, an account where the money lands, and compliance obligations that no line of code can replace. ChariPay is operated by Chari Money, a Bank Al-Maghrib-licensed payment institution; the platform is PCI DSS Level 1 certified, renewed every year, and personal data is processed under Law 09-08. The page on the payment gateway in Morocco explains what that framework covers and how to check a license; here we draw the technical consequences.

Before you write a line, five points separate an API "that answers" from an API you can launch a product on:

  • A licensed operator, by name. You must be able to answer the question "which entity is licensed, and do my funds go through it?". At ChariPay the answer is one name: Chari Money.
  • Settlement in dirhams, in an account in the company's name. The API has no currency field: everything is in MAD, in major units with two decimals (149.90). Every successful payment is available instantly in the merchant's payment account — a payment account held by Chari Money, with a RIB in the company's name, readable through the API.
  • 3-D Secure on cards, with no card data on your side. Visa, Mastercard and Maroc Pay are entered on the hosted checkout page, inside a PCI DSS Level 1 certified perimeter. Your server never sees a card number.
  • Cash at an agency, through the same webhook. ChariPay is the only payment gateway in Morocco that also collects cash at an agency: the buyer receives a reference (that of a single-use payment link), pays it at an agency of the Chari network, and you receive payment.succeeded exactly as for a card. Your webhook handler does not change by a single line.
  • Signed webhooks, documented idempotency, errors with stable codes — and public documentation. The API documentation is generated from the OpenAPI specification the API publishes, downloadable along with the Postman collection: you can check every claim in this guide against the contract itself.

You do not need to integrate everything. The API offers three entry points, and the choice depends on how you sell — not on your technical level.

PatternFor whomWhat you writeCard dataEntry point
Payment linkSelling without a website: quotes, invoices, WhatsApp, counterOne server call, or none (the portal is enough)Never on your sidePOST /v1/payment-links
Checkout session + hosted checkoutE-commerce site or app with an order flowOne server-side create, one redirect, one webhookNever on your sidePOST /v1/payment-sessions
Direct checkoutYour own card form, field by fieldVerify → submit → 3-D Secure → returnInside your compliance scope/checkout/* with the vk key

The payment link is the shortest pattern: an amount, a description, and the API returns a hosted payment page to share — QR code and printable PDF poster included. It is the right answer for a quote accepted on WhatsApp, a membership fee, a deposit, a sale at the counter. The Payment links page shows what the buyer sees; the Payment links module of the documentation lists the seven endpoints.

The checkout session is made for a merchant site: an order becomes a session, you redirect the buyer to the 3-D Secure hosted checkout, and you confirm the order when the webhook arrives. The Online payments page describes the flow; the Checkout sessions module details the fields.

Direct checkout only makes sense if you have a precise reason to draw your own card screen: it places you inside the card-data compliance scope, and its endpoints are not authenticated by your API key but by the session and a single-use verification key. For nearly every project, the first two patterns are enough — including for a Shopify or WooCommerce store, which you connect with a payment link or a session created server-side, with no plugin to install.

Authentication and environments

Every request carries your API key in the X-CHARI-PAY-API-KEY header. There is no OAuth flow and no token to refresh: the key is enough, and the key is what determines the environment. A sandbox key starts with chari_sk_test_, a production key with chari_sk_live_. There is a single base URL, https://api-psp.charipay.ma, in test and in production alike: you do not choose the environment in the URL or in a header, you choose it by choosing the key. That is deliberate — it becomes impossible to send a test request to production by mistake, and going live requires no change of address.

Your test key is within reach right now: Start in test mode. You fill in three fields (name, business e-mail, company), then an activation link arrives by e-mail so you can choose your password; next, you create your chari_sk_test_… key from the portal and make your first test payment with the test card. Test mode is free, with no time limit and no approval to wait for; the verification of your company (KYB) only gates going live.

Three rules for keys, before the very first call:

  1. 1A key is a secret. It lives on your server, in an environment variable or a vault. Never in code run by the browser, never in a mobile app, never in a git repository.
  2. 2A key has a scope. Keys are issued per company and per environment, with permissions per module: grant only what your integration actually calls. The full key is displayed only once, at creation — copy it at that moment.
  3. 3**The /checkout/* endpoints are the exception.** They are executed by the buyer's browser and carried by the session itself, with its single-use vk verification key. Never send your API key there.

The right habits — rotation, leaks, keys shared within a team — are covered in Protecting your API keys.

The first useful call takes three fields: an amount in dirhams, a description shown to the buyer, and an externalId derived from your order reference, which makes the call replayable. The Idempotency-Key header protects against a network retry — more on that below.

bash
curl -X POST 'https://api-psp.charipay.ma/v1/payment-links' \
  -H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-1042-link' \
  -d '{
    "amount": 149.90,
    "description": "Order #1042",
    "singleUse": true,
    "externalId": "order-1042",
    "customerEmail": "amine.bennani@example.com",
    "customerPhone": "+212600000000",
    "metadata": { "cartId": "c_987" }
  }'

The same call in Node, with fetch and the key read from the environment:

javascript
const response = await fetch('https://api-psp.charipay.ma/v1/payment-links', {
  method: 'POST',
  headers: {
    'X-CHARI-PAY-API-KEY': process.env.CHARI_PAY_API_KEY,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-1042-link',
  },
  body: JSON.stringify({
    amount: 149.9,
    description: 'Order #1042',
    singleUse: true,
    externalId: 'order-1042',
    customerEmail: 'amine.bennani@example.com',
    metadata: { cartId: 'c_987' },
  }),
});

if (!response.ok) throw new Error(await response.text());
const link = await response.json();
// link.reference — e.g. "pl_3ND8xk"; link.payUrl — the payment page to share

And in Python, with requests:

python
import os, requests

response = requests.post(
    'https://api-psp.charipay.ma/v1/payment-links',
    headers={
        'X-CHARI-PAY-API-KEY': os.environ['CHARI_PAY_API_KEY'],
        'Content-Type': 'application/json',
        'Idempotency-Key': 'order-1042-link',
    },
    json={
        'amount': 149.90,
        'description': 'Order #1042',
        'singleUse': True,
        'externalId': 'order-1042',
        'customerEmail': 'amine.bennani@example.com',
        'metadata': {'cartId': 'c_987'},
    },
)
response.raise_for_status()
link = response.json()
print(link['reference'], link['payUrl'])

The response comes back as 201 with the link's reference (of the form pl_…), its status (ACTIVE), the currency (MAD, always) and the payUrl — the hosted page you send the buyer to. If you replay the call with the same externalId, the API returns the existing link with 200 instead of creating a second one: test the status, both are successes.

Four options are worth knowing from day one. singleUse: false makes the link reusable — handy for a poster in a shop window or a membership fee. expiresAt sets an end date (ISO-8601 UTC, in the future). paymentMethod: "CASH" creates a link to be paid in cash: the buyer receives a cashinCode to present at an agency, and the payment.succeeded webhook confirms the deposit. Finally, acceptUrl, declineUrl and notificationUrl — all https:// — customize the return and the notification for that specific link. The QR code (GET /v1/payment-links/{reference}/qr), the PDF poster (/poster) and e-mail sending (POST …/send) are endpoints of the same module; POST …/cancel makes a link unpayable if the sale falls through.

Create a checkout session and handle the return

For a merchant site, the checkout session replaces the link: it carries your orderId, the buyer, and your return URLs. You create it server-side, redirect to the returned checkoutUrl, and wait for the webhook.

bash
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://your-store.ma/payment/success",
        "decline": "https://your-store.ma/payment/failure",
        "notification": "https://your-store.ma/webhooks/charipay"
      }
    },
    "metadata": { "cartId": "c_987", "source": "web" }
  }'

The response, a 201, contains the five fields set by the contract: the sessionId, the server-side sessionToken, the verifyKey verification key, the session's expiresAt, and the checkoutUrl to redirect the buyer to:

json
{
  "sessionId": "ps_5Kd0Rn",
  "sessionToken": "st_…",
  "verifyKey": "vk_5Kd0Rn",
  "expiresAt": "2026-10-05T10:15:00Z",
  "checkoutUrl": "https://…/checkout/ps_5Kd0Rn"
}

A few notes on the fields. verifyKey is the single-use verification key passed as the vk parameter: it only concerns you with direct checkout; with the hosted checkout, the checkoutUrl is all you need. The session's status, amount and expiry are read with GET /v1/payment-sessions/{sessionId}. orderId is your business reference: displayed, echoed in webhooks, with no uniqueness constraint. externalId is different: unique per merchant and per environment, it makes the create replayable. The four fields of config.customer are required; the URLs in config.urls are all optional and must be https://: if accept or decline is missing, the value configured on your account applies, then the platform default; if notification is missing, only your account's value applies. A session is single-use and expires by default 72 hours after creation; expiresAt shortens that. config.keepAlive: true lets the buyer retry after a failure; notifyOnFailure: true also sends you payment.failed. The metadata object (at most 4 KB) is echoed as-is in the payment webhook.

The buyer's return to your site is where most integrations go wrong. The rule fits in one sentence: trust the webhook, not the redirect. Here is the correct sequence:

  1. 1Create the session server-side and store the sessionId against your order, in the state "awaiting payment".
  2. 2Redirect the buyer to checkoutUrl. There they pay by card with 3-D Secure. A session collects cards only; for an order to be paid in cash at an agency, create a single-use payment link, as in the section on payment links: the webhook is the same.
  3. 3When they land on `accept`, show a pending state — "payment being confirmed" — not a final success page. The redirect says the buyer came back; it does not say the money arrived. A buyer who closes the tab after paying will never reach that page, and the money is there all the same.
  4. 4Confirm the order when `payment.succeeded` arrives, after verifying the signature. It is the only source of truth.
  5. 5As a fallback, if nothing has arrived after a reasonable delay, query GET /v1/payment-sessions/{sessionId} or the transactions list — polling is a safety net, not a mode of operation.

Webhooks: verifying the signature

A payment does not complete when you call it: the buyer goes through their bank, clears 3-D Secure, comes back — or does not. The result reaches you by webhook. You declare your receiving URLs from the portal or with POST /api/v1/partner/webhooks/endpoints, with an explicit list of events (enabledEvents): subscribe only to what you handle. The URL must be public HTTPS on port 443; localhost and private addresses are rejected at registration — in development, use a tunnel. Each endpoint gets its own signing secret, to be kept like an API key, and secrets are distinct between sandbox and production.

Every delivery carries two headers: X-CHARI-SIGNATURE, a lowercase hex HMAC-SHA256 computed over the string timestamp + "." + rawBody, and X-CHARI-TIMESTAMP, the timestamp in epoch milliseconds. Here is the verification, taken from the API documentation:

javascript
const crypto = require('crypto');

function verify(rawBody, signature, timestamp, secret) {
  // ±5-minute anti-replay window — the timestamp is in milliseconds.
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;

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

  // A malformed signature must return false — never throw (500 → retries).
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false;

  // Constant-time comparison: never `===`.
  return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}

Four rules carry the entire security of this verification. Compute the HMAC over the raw bytes, before any parsing: a framework that deserializes the JSON and re-serializes it no longer produces the bytes that were signed — that is the most common mistake. Reject a timestamp skewed by more than ±5 minutes. Compare in constant time, never with ===. And during a secret rotation, accept either of the two signatures: while the old secret is still in its grace window, every delivery carries X-CHARI-SIGNATURE with the old secret and X-CHARI-SIGNATURE-NEXT with the new one.

Then comes deduplication, and it is the distinction that costs the most when you get it wrong: deduplicate on `Chari-Event-Id`, not on `Chari-Webhook-Id`. 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. Delivery is at least once and may arrive out of order — by design. Answer 2xx only once both the business update and the dedup record have committed, and do the heavy work in the background: a handler that takes ten seconds eventually triggers redeliveries, then the suspension of your endpoint.

If your server does not answer 2xx, we retry: 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. The portal's delivery log shows every attempt, your server's response and the exact event body, and lets you replay in one click. After a longer outage, reconcile with GET /v1/transactions rather than waiting for a webhook that will not come back.

The events you will wait for most: payment.succeeded, payment.failed (if requested at creation), order.paid, refund.succeeded, refund.failed, and for subscriptions subscription.payment_succeeded, subscription.payment_failed, subscription.canceled. Your metadata and externalId come back on every event of the resource that carried them: that is how you reconcile without storing our references. A test event is available on every declared endpoint — send it before the first real payment. The Webhooks module lists the twenty events actually emitted; Getting your webhook integration right details the five classic mistakes and the complete handler in fifteen lines.

Idempotency

The network drops between your server and ours, your HTTP client retries, and you do not know whether the first attempt went through. In payments, blind retries are the best way to charge the same customer twice. Two independent mechanisms answer two different failures, and they stack.

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, a message queue delivered twice. Its barrier is scoped to the account — keep distinct keys between your tests and your production.

The `externalId` field protects against business re-issue. Unique per account and per environment, it turns the create into an operation you can rerun without keeping any intermediate state: 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 rerun, a queue replayed, a double click in a back office. The same externalId may exist once in sandbox and once in production without conflict.

Refunds use `refundReference` for the same reason: a fresh refund answers 202 Accepted — it executes asynchronously — and a replay of the same reference returns the existing refund with 200, without debiting twice. Never 201. In all three cases, derive the key from your order reference — never from randomness, which would defeat the whole point. Good key patterns, and what idempotency does not do, are in Never charge twice — idempotency explained.

Errors and correlationId

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.

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount: must be greater than 0"
  },
  "correlationId": "b0c1e2d3-4f56-7890-abcd-ef0123456789"
}

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, successes included — log it systematically, it is what lets us find one precise request in our logs. If you send your own X-Request-Id, it is echoed on the response and becomes the correlationId.

StatusCodesWhat it means, and what to do
400VALIDATION_ERROR, MISSING_PARAMETER, INVALID_IDEMPOTENCY_KEYA field fails validation (zero amount, non-https:// URL, idempotency key missing or too long on a reusable session). Fix it, do not replay as-is.
401UNAUTHORIZED, INVALID_TOKENAPI key missing or invalid: check the X-CHARI-PAY-API-KEY header and the key's environment. On /checkout/*, INVALID_TOKEN signals a vk key that is invalid or already consumed.
403FORBIDDEN, PRODUCTION_ACCESS_NOT_ENABLEDThe key lacks a permission, or a production key is used on an account whose production is not yet enabled.
404OPERATION_NOT_FOUND, ORDER_NOT_FOUND, SESSION_NOT_FOUNDThe resource does not exist in this environment — a sandbox identifier is worth nothing in production.
409IDEMPOTENCY_CONFLICT, SESSION_ALREADY_CONSUMED, SESSION_NOT_ACTIVESame idempotency key with a different body, or a session already consumed: create a new one.
410SESSION_EXPIREDThe session is past its expiresAt. Create a new session.
422WALLET_NOT_ACTIVE, PAYMENT_METHOD_CONSENT_REQUIREDValid request, but a business rule blocks it.
429RATE_LIMITEDToo many requests: slow down and read the Retry-After header. Publicly exposed endpoints announce X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
502BAAS_CHARI_ERRORIn the sandbox, this is almost always a card other than the test card. Check the PAN entered.
5xx—On our side. Replay with your idempotency key rather than creating a new resource.

Two conventions complete the table. Any URL you declare — return, notification — must be https://, on pain of a 400. And tolerate new enum values as well as absent optional fields — never null: that is how the API evolves without breaking you. The sign-up and portal endpoints have their own error format, with ERR-XXXX codes; the envelope above applies to the merchant API, the one your key calls.

Refunds, subscriptions, payment account through the API

Once the first payment is in place, the rest of the API plugs in at the pace of your product. Three modules show up in almost every integration.

Refunding. POST /v1/refunds with the payment identifier — operationId or externalId —, a reason and a refundReference of your choice; in full by default, or for a partial amount with refundAmount. The API answers 202 while the refund settles, then refund.succeeded arrives by webhook — a refund is not instant on the bank side. Refunding a customer is free, and the amount is deducted from the available balance of your payment account.

bash
curl -X POST 'https://api-psp.charipay.ma/v1/refunds' \
  -H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "externalId": "order-2026-0421",
    "refundReference": "refund-order-2026-0421",
    "refundAmount": 100.00,
    "reason": "Item returned"
  }'

The Refunds module describes the three endpoints and the statuses.

Subscriptions. Create the client (POST /v1/clients) then the subscription (POST /v1/subscriptions) with its period and amount; the first payment happens with the customer present, on the hosted checkout, with their explicit consent to keep their payment method on file. A pre-debit notice goes out by e-mail before each due date. A failed charge is retried on the due date, then at +1, +3 and +7 days, with a fallback payment link sent to the customer, after which the subscription is canceled — you follow subscription.payment_failed and subscription.canceled. In the sandbox, POST /v1/subscriptions/{reference}/test-auto-pay forces the next due date: you run through a year of subscription in a minute and check your billing before a real customer has to live with it. The Subscriptions module details the nine endpoints — pause, resume, cancel, charges.

The payment account. The money from every successful payment is available instantly in your ChariPay payment account, held by Chari Money, and the API shows it to you: GET /v1/wallet returns the balance, GET /v1/wallet/account the RIB in your company's name, GET /v1/wallet/account/rib-document the PDF certificate, and POST /v1/wallet/cash-ins funds the account ahead of an outgoing operation. From that account you transfer to a Moroccan bank account and pay bills from the portal; telecom top-ups, for their part, go through the API. Outgoing movements are notified by webhook (merchant_transfer.completed, bill_payment.succeeded, topup.succeeded, and their failures). The Wallet module lists the four endpoints; the Wallet and payouts page describes what you can do from the account, including the automatic nightly payout once a settlement account is configured.

Testing in the sandbox

You wait for no one to get started: the sandbox is self-serve, free and without time limit, and test mode opens the moment you sign up — the verification of your company runs in parallel and only gates production. Eight steps separate a blank page from an authenticated call, and the Postman collection runs them in order:

  1. 1Sign up: create your test account — name, business e-mail, company.
  2. 2Activate the account with the link received by e-mail and choose your password; the activation token exists only in that e-mail.
  3. 3Log in to the portal; depending on your account's permissions, a TOTP app may be required — keep your recovery codes.
  4. 4Select your company: that response, not the login, carries your session token.
  5. 5Pass the step-up re-authentication: creating a key is a sensitive action, a short-lived confirmation token is required.
  6. 6Look at the available permissions and grant only the ones your integration calls.
  7. 7Create your `chari_sk_test_…` key — it is displayed only once: put it straight into a vault or an environment variable.
  8. 8Check with GET /v1/wallet: if the balance answers, you are authenticated.

In the sandbox, a single test card is accepted: 4918 9141 0719 5005 (entered without spaces), CVV 123, any future expiry date, and 555 as the 3-D Secure code. It triggers a genuine flow — checkout page, authentication, webhook — with no money moving. Any other PAN, including the 4242… cards of other platforms, is rejected upstream with a 502 and the BAAS_CHARI_ERROR code: if you hit that error while testing, check the card number first.

Test something other than the happy path before thinking about production: a declined payment, a cash link and its cashinCode, a partial refund, a subscription whose next three due dates you force, a webhook to which your server answers an error — to see the retry at work —, and the test event on every declared endpoint. The sandbox remains your staging environment long after launch: keep two sets of environment variables.

Going live

The sandbox opens immediately; production is enabled on your account after your company is verified. Until then, a chari_sk_live_… key gets an explicit 403 with the code PRODUCTION_ACCESS_NOT_ENABLED — not an integration bug, an administrative step. The steps, in order: KYB verification (identity documents and trade register, with dual human review), the review of your file and the priced proposal — a percentage commission per successful transaction and a deposit, set according to your products, payment methods and volumes —, the one-time activation fee of 6,000 MAD incl. VAT, then activation. The full grid is on the Pricing page. We do not promise a turnaround time: every file is reviewed.

On switch-over day you change two things, and nothing else: the API key — chari_sk_test_… becomes chari_sk_live_… — and the webhook secret, which is distinct in production. URLs, payloads, error codes and the https://api-psp.charipay.ma base are identical. Before switching, five checks are worth the time they take: your webhook handler verifies the signature over the raw body and is idempotent; your creates send an idempotency key derived from your reference; you log the correlationId of every call; your keys live in a vault, not in the repository; you have tested a refund and a failed payment. The full list, with the three most frequent omissions, is in Sandbox to production: the go-live list.

Downloads

Everything this guide states can be checked against the contract. Three resources are served freely, with no account:

  • OpenAPI specification — the contract itself, the one the online reference is generated from, with no rewording; load it into your client generator, your testing tool or your editor.
  • Postman collection — all 62 endpoints ready to run, with the eight sign-up requests up front: from nothing to your first key without leaving Postman.
  • LLM pack — one Markdown per module, the authentication, webhooks and errors guides, the test card and the specification: what a coding assistant needs to read to integrate the API offline.

The API documentation covers the twelve modules and sixty-two endpoints with examples in curl, JavaScript, Python and PHP; the Developers page sums up the conventions and the path from the first call to production.

Frequently asked questions

Is there a free payment API in Morocco for testing?

Yes. The ChariPay sandbox is free, has no time limit and is self-serve: you sign up online, activate your account by e-mail and create your own chari_sk_test_… key, with no meeting and no e-mail to support. It runs on the same endpoints as production, sends real signed webhooks and accepts the test card 4918 9141 0719 5005. Only going live is paid.

What is the base URL and how do I choose the environment?

There is a single base URL, https://api-psp.charipay.ma, in sandbox and in production alike. The API key chooses the environment: chari_sk_test_… targets the sandbox, chari_sk_live_… production. URLs, payloads and error codes are identical in both cases; on go-live day you change the key and the webhook secret, nothing else.

How do I verify a webhook?

Recompute an HMAC-SHA256 with your endpoint secret over the string X-CHARI-TIMESTAMP + "." + raw body, before any parsing, and compare it in constant time with X-CHARI-SIGNATURE. Reject timestamps skewed by more than ±5 minutes, deduplicate on Chari-Event-Id, and answer 2xx only once the processing is recorded. The complete Node verification code is above and in the Webhooks guide of the API documentation.

What happens if my server is unreachable?

We retry the delivery: immediately, then 1 min, 5 min, 30 min, 1 h, then every 6 h, up to 16 attempts over roughly 72 hours. Each attempt carries a new Chari-Webhook-Id but the same Chari-Event-Id. Past that, the delivery is marked failed; the portal log lets you replay it in one click, and GET /v1/transactions is there to reconcile after a longer outage.

How do I avoid charging twice?

Send an Idempotency-Key header on every create: a network retry with the same value returns the first result. Add an externalId derived from your order reference: a re-issue returns the existing resource with 200 instead of 201. For a refund, the refundReference plays the same role — 202 on creation, 200 on replay. And deduplicate your webhooks on Chari-Event-Id.

Can I integrate Shopify or WooCommerce through the API?

Yes, with no plugin to install: none exists, and none is needed. For a store without a developer, a payment link created from the portal is sent at order time. With a developer, your server creates a checkout session when the cart is validated, redirects to the hosted checkout, and confirms the order on payment.succeeded. The Shopify and WooCommerce guides on this blog detail both paths.

How do I collect cash through the API?

Create a single-use payment link with paymentMethod: "CASH", or let the buyer choose cash on the link's page; a session, for its part, collects cards only. They receive a reference — the link's cashinCode — to present at an agency of the Chari network, deposit the amount, and you receive payment.succeeded: same webhook, same deduplication, same reconciliation as for a card. ChariPay is the only payment gateway in Morocco that also collects cash at an agency.

How long does it take to go live?

We do not publish a turnaround time, because every file is reviewed. The steps are known: KYB verification with your identity documents and trade register, review of the file and a priced proposal, payment of the one-time 6,000 MAD incl. VAT activation fee, then activation of production on your account. Your integration does not wait: it is built in the sandbox in the meantime, and switches over by changing the key.

Next step

Open your test account — Start in test mode — then make your first POST /v1/payment-links and pay the link with the test card. Next, open the Developers page for the conventions and the API documentation for every endpoint. When payment.succeeded reaches your server with a valid signature, you have done the essential part: going live is a change of key.

Written by ChariPay team.

Read next

Technical5 min read

Getting your webhook integration right

Getting your webhook integration right: the five mistakes found in almost every integration, and the fifteen-line handler that avoids all of them.

Technical4 min read

Never charge twice — idempotency explained

Payment idempotency explained: how the Idempotency-Key header and externalId stop you charging a customer twice when the network drops mid-call.

Guide4 min read

Sandbox to production: the go-live list

Moving from sandbox to production: the checklist before you switch keys — signed webhooks, idempotency, secrets, the classic oversights.

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