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.
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.
Reference generated from the OpenAPI specification published by the API — it cannot drift from the contract.
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.maSandboxchari_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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "amount: must be greater than 0"
},
"correlationId": "b0c1e2d3-4f56-7890-abcd-ef0123456789"
}| Status | Codes | Meaning |
|---|---|---|
| 400 | INVALID_IDEMPOTENCY_KEY · MISSING_PARAMETER · VALIDATION_ERROR | Malformed request or failed validation (code = VALIDATION_ERROR / MISSING_PARAMETER). |
| 401 | INVALID_TOKEN · UNAUTHORIZED | Missing or invalid API key (code = UNAUTHORIZED). |
| 403 | FORBIDDEN · PRODUCTION_ACCESS_DENIED | Authenticated but the key lacks the required permission, or production access is not enabled (code = FORBIDDEN / PRODUCTION_ACCESS_DENIED). |
| 404 | OPERATION_NOT_FOUND · ORDER_NOT_FOUND · SESSION_NOT_FOUND | No such link for this account. |
| 409 | IDEMPOTENCY_CONFLICT · SESSION_ALREADY_CONSUMED · SESSION_NOT_ACTIVE | Session already paid or canceled (SESSION_ALREADY_CONSUMED / SESSION_NOT_ACTIVE). |
| 410 | SESSION_EXPIRED | Session expired (SESSION_EXPIRED). |
| 422 | PAYMENT_METHOD_CONSENT_REQUIRED · WALLET_NOT_ACTIVE | Syntactically valid but a business rule blocks it (e.g. WALLET_NOT_ACTIVE). |
| 429 | RATE_LIMITED | Rate limit exceeded (code = RATE_LIMITED). Retry after the Retry-After header. |
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.
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.
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'));
}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.
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
An amount, a description, a link to send. The customer pays on a page hosted by ChariPay — you host no card data.
Open the module4 endpoints
An e-commerce order becomes a session: you redirect the buyer to the hosted checkout and collect the result.
Open the module4 endpoints
The calls the payment page makes itself: session verification, card submission, 3-D Secure return.
Open the module4 endpoints
Everything that came in and went out: payments, refunds, payouts, fees. With the detail of an operation and its timeline.
Open the module3 endpoints
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 module4 endpoints
The balance of your payment account, its RIB in the merchant's name, the downloadable certificate, and how to fund it.
Open the module8 endpoints
The registry of your end customers and their saved payment methods, reused for subscriptions and recurring charges without re-entering details.
Open the module6 endpoints
A simple catalogue of your products and their orders, to sell through payment links or checkout without building a full online store.
Open the module9 endpoints
A recurring charge on a saved payment method, with its due dates, pauses and dunning.
Open the module10 endpoints
Your receiving URLs, their signing secrets, the list of emitted events and the detail of every delivery.
Open the module1 endpoint
The list of every event type you can subscribe to.
Open the module2 endpoints
What happened between opening a link or a session and the payment: the steps taken, the drop-offs, the traffic source.
Open the moduleWhat 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.
JSON · 126 Ko
All 62 endpoints, ready to run, with the sign-up requests up front: from nothing to your first API key without leaving Postman. Every request carries its description and captures what it produces into the collection variables.
Download — chari-pay-api.postman_collection.jsonJSON · 180 Ko
The contract itself — the one this reference is generated from, with no rewording. Load it into your client generator, your testing tool or your editor.
Download — charipay-openapi.jsonZIP · 57 Ko
One Markdown per module, the authentication, webhooks and errors guides, the test card and the specification: everything a coding assistant needs to read to integrate the API, offline, without browsing the site.
MarkdownOpenAPI 3.0cURLllms.txt
Download — charipay-llm-pack.zipThe words this documentation uses in a precise sense.
chari_sk_test_ in the sandbox, chari_sk_live_ in production.Our technical team supports integrators from the first sandbox call through to go-live.