Test a webhook locally: HTTPS tunnel, test event, delivery log and secret rotation
Test a webhook locally when localhost is refused: an HTTPS tunnel, the signed test event, the delivery log, retries, suspension and signing secret rotation.
To test a webhook locally, expose your development server behind an HTTPS tunnel, register the tunnel's public URL as an endpoint with your test key, then send yourself the test event: a synthetic payment.succeeded, signed like a real one. The API refuses localhost, private addresses and any non-HTTPS URL at registration time, so the tunnel is the normal path, not a workaround. This guide covers registering the endpoint, the development loop, reading the delivery log, the retry schedule, suspension, rotating the secret without downtime, and keeping sandbox and production apart.
Register the endpoint, from the portal or through the API
ChariPay is a Moroccan payment platform, a PSP operated by Chari Money, a payment institution licensed by Bank Al-Maghrib. Its webhooks are signed requests our servers send to yours when something happens: a successful payment, a refund, a subscription installment. The address that receives them is called an endpoint. You register it from the portal or through the API; through the API, the key you use decides whether the endpoint belongs to the sandbox or to production.
| Field | Role | Keep in mind |
|---|---|---|
url | receiving address | public HTTPS, port 443; localhost, private addresses and non-HTTPS URLs are refused at registration |
enabledEvents | events you receive | an explicit list is recommended; left empty, it means every current and future event |
description | label | to recognize the endpoint in the portal |
customHeaders | static routing headers, optional | added to every delivery; never give them the name of a header ChariPay sends |
Through the API, registration is a single call:
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": "Paiements boutique",
"enabledEvents": ["payment.succeeded", "refund.succeeded", "refund.failed"]
}'The response contains the signingSecret (whsec_…), shown only once — at creation, then at each rotation — and never returned by a later read. Store it right away in a secrets vault or an environment variable, with the same care as a key: the rules are in protecting your API keys. Reading, updating — URL, event list, headers, enabled state — or deleting an endpoint is done with GET, PATCH and DELETE on /api/v1/partner/webhooks/endpoints/{id}, all described in the Webhooks module of the API docs.
A checkout session or a payment link can also name its own receiving URL — config.urls.notification for a session, notificationUrl for a link — which gets the same signed events. That is handy to route a specific store; for the development loop, start from a registered endpoint, because that is where the test event is sent.
Why localhost is refused, and how to test a webhook locally
The rule applies at registration: the URL must be public HTTPS on port 443, and localhost, private addresses and non-HTTPS URLs are refused. A webhook travels from our servers to yours: a localhost or private address points to nothing reachable from the internet, and a plain-HTTP URL would send payment data unencrypted. In development, the answer is an HTTPS tunnel: a service that gives your machine a public https:// address and relays requests to your local server. Any tunnel that provides a public HTTPS URL on port 443 will do; we do not recommend any particular one.
The development loop, in order:
- 1Start your local server with a receiving route — for example
/webhooks/charipay— that keeps the raw request body. - 2Open an HTTPS tunnel to that server and note the public URL it assigns you.
- 3Register that URL, followed by your route, as a sandbox endpoint — from the portal, or with the call above using your test key — and store the
signingSecretin your local configuration. - 4Send the test event and watch the request arrive in your server logs.
- 5Read the delivery log to see what your server answered.
- 6Clean up: if the tunnel URL changes every time you start it, update the existing endpoint with
PATCHrather than creating a new one, and delete abandoned development endpoints.
At step 3, all you are missing is a test key to register a sandbox endpoint: Start in test mode. Three fields — name, work email, company — then an activation link received by email to choose your password, then the portal, where you create your own test key after re-authenticating. Test mode is free, with no time limit, and your business verification only gates the move to production.
Send the test event and check the response
Every registered endpoint has a test event, triggered from the portal or with POST /api/v1/partner/webhooks/endpoints/{id}/test. It sends a synthetic payment.succeeded carrying Test: true, signed exactly like a real event, and it goes out even if payment.succeeded is not in your enabledEvents list. The call returns the delivery ID, which you then read back with GET /api/v1/partner/webhooks/events/{deliveryId} to see how your server responded.
Three checks make a good test:
- The signature is accepted. If your verification fails on the test event, it will fail on real ones. Two causes to check first: a re-serialized body instead of the raw body, or the secret of another endpoint.
- The response is a `2xx`, and it is fast. Answer
2xxonce the deduplication record and the business update are stored; heavy work goes to a background job. Any response other than a2xx, or no response at all, triggers retries. - No business side effect. The test event matches no real payment: your code recognizes it by
Test: trueand marks no order as paid.
The receiver code — verification on the raw body, deduplication, acknowledgment — is not repeated here: it is explained step by step in getting your webhook integration right, along with the five classic mistakes. Once the test event passes, move on to a real sandbox payment with testing payments in test mode: test card, return to your page, real event.
Read the delivery log
The delivery log answers the two questions behind any debugging session: did we send it, and what did your server answer? You can read it in the portal or through the API. GET /api/v1/partner/webhooks/events lists the events emitted for your key's environment, newest first, with filters by endpoint, event type, delivery status and time window. GET /api/v1/partner/webhooks/events/{deliveryId} details one delivery: number of attempts, the HTTP status your server returned on the last attempt, the next scheduled attempt, and the exact body that was signed and sent — enough to recompute the signature locally when your verification fails.
Six delivery headers are worth knowing, spelling and case included:
| Header | What it carries | What it is for |
|---|---|---|
X-CHARI-SIGNATURE | the delivery's signature | checking authenticity |
X-CHARI-SIGNATURE-NEXT | the signature with the new secret, during a rotation only | switching secrets without downtime |
X-CHARI-TIMESTAMP | the send time, in milliseconds since epoch | feeding verification, rejecting replays |
Chari-Webhook-Id | the delivery's identifier | tying a received request to a delivery |
Chari-Event-Id | the event's identifier, stable across all attempts and all your endpoints | deduplicating |
Chari-Event-Type | the event type, for example payment.succeeded | routing the handler |
Deduplicate on Chari-Event-Id, not on Chari-Webhook-Id — the latter identifies the delivery, not the event. When you investigate an order, note both: one tells you which event it is, the other which delivery you are looking at. To find the order behind a real event, the method is in matching a payment to its order.
Retries, suspension and reactivation
Any response other than a 2xx — or no response at all — triggers retries on a fixed schedule:
| Attempt | Timing |
|---|---|
| 1st | immediate |
| 2nd | 1 min after the previous one |
| 3rd | 5 min after the previous one |
| 4th | 30 min after the previous one |
| 5th | 1 h after the previous one |
| following | every 6 h, up to 16 attempts over about 72 hours |
After that, the delivery is marked failed; it can be replayed in one click from the portal. An endpoint that keeps failing is suspended — the threshold is not published. Fix the cause, reactivate the endpoint from the portal or with POST /api/v1/partner/webhooks/endpoints/{id}/activate, then check with the test event. Queued events are retained during the suspension and resume in order on reactivation.
In development, this schedule has a practical consequence: a tunnel closed overnight piles up failures on your sandbox endpoint. Disable the endpoint with PATCH when you stop working: otherwise, repeated failures can get it suspended, and you will have to reactivate it. In production, after an outage longer than the retry window, stop waiting for the webhook: reconcile with GET /v1/transactions, which lists the transactions for the period.
Rotating the webhook signing secret without downtime
You change a secret when a team member leaves, when it may have leaked, or simply on a regular schedule. Rotation is designed so that no event is lost:
- 1Request a new secret from the portal or with
POST /api/v1/partner/webhooks/endpoints/{id}/rotate-secret: the response contains it, only once. - 2During the rotation, every delivery carries two signatures:
X-CHARI-SIGNATURE, computed with the old secret, andX-CHARI-SIGNATURE-NEXT, computed with the new one. - 3Accept either one: verify with the secret you hold — the old one against
X-CHARI-SIGNATURE, the new one againstX-CHARI-SIGNATURE-NEXT. - 4Deploy the new secret to all your servers, then switch your verification to it: against
X-CHARI-SIGNATURE-NEXTwhile that header is present, againstX-CHARI-SIGNATUREafterwards. - 5Remove the old secret from your configuration once the switch is done.
While the endpoint's previousSecretExpiresAt field is set, the old secret still verifies and both signatures are sent. Once the rotation is over, X-CHARI-SIGNATURE-NEXT disappears and X-CHARI-SIGNATURE is computed with the new secret, now the endpoint's only one: a verification that only read X-CHARI-SIGNATURE-NEXT would then reject every delivery. The verification itself does not change: it is the same function, called with a different signature-and-secret pair.
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'));
}Rehearse the rotation in the sandbox before you need it in production: request a new secret, send the test event, and check in the log that your server answered 2xx with the old secret, then with the new one.
Sandbox and production: two endpoints, two secrets
An endpoint belongs to a single environment, that of the key that registered it when you go through the API: a sandbox endpoint only receives sandbox events, a production endpoint only production events, and their secrets differ. An endpoint never moves between environments: at launch, you register a new one with your production key. In your code, you change only the key and the webhook secret; URLs, payloads and error codes are identical. A mistake to avoid: keeping the sandbox secret in production — your verification would then reject every notification.
Pick your events from the twenty documented in the Event catalog module of the API docs — payments (payment.succeeded, payment.failed, order.paid), subscriptions, refunds, account movements, distribution — and always route on the Chari-Event-Type header. payment.failed is only sent if you asked for it when creating the session (notifyOnFailure): do not build any flow on it. For a link paid in cash at an agency, follow the link's state: as soon as the network confirms the deposit, the link turns "Paid" and you are notified in the portal; on the server side, read the link's state with GET /v1/payment-links/{reference}.
Finally, the same event can arrive more than once, and out of order: that is by design. The full reasoning — why deduplicate, and where — is in payment idempotency.
Frequently asked questions
Can I register a localhost address as a webhook endpoint?
No. The URL must be public HTTPS on port 443: localhost, private addresses and non-HTTPS URLs are refused at registration. In development, open an HTTPS tunnel to your local server and register the public URL it gives you, with your test key. If that URL changes, update the endpoint with PATCH instead of creating another one.
How do I send a test event to my endpoint?
From the portal, or with POST /api/v1/partner/webhooks/endpoints/{id}/test. The call sends a synthetic payment.succeeded carrying Test: true, signed like a real one, even if that event is not in your list. It returns the delivery ID: read it back with GET /api/v1/partner/webhooks/events/{deliveryId} to see what your server answered. That way, you validate your signature before any payment.
How do I tell the test event apart from a real payment?
The test event carries Test: true and matches no real payment. Its signature is identical to a real event's, so do not rely on it to tell them apart. Your code should recognize Test: true, answer 2xx and trigger no business side effect — no order marked as paid, no email sent to the customer.
How many times is a webhook resent when my server does not respond?
Without a 2xx response, the immediate first attempt is followed by retries at 1 min, 5 min, 30 min, 1 h, then every 6 h, up to 16 attempts over about 72 hours. After that, the delivery is marked failed and can be replayed in one click from the portal. After a longer outage, reconcile with GET /v1/transactions.
What should I do when a webhook endpoint is suspended?
An endpoint is suspended after repeated failures. First fix the cause using the delivery log — HTTP status, response time, signature — then reactivate it from the portal or with POST /api/v1/partner/webhooks/endpoints/{id}/activate. Queued events are retained and resume in order; then check everything with the test event.
How do I rotate the webhook secret without losing events?
Request a new secret from the portal or with POST …/endpoints/{id}/rotate-secret. During the rotation, every delivery carries X-CHARI-SIGNATURE with the old secret and X-CHARI-SIGNATURE-NEXT with the new one: accept either. Deploy the new secret to all your servers, switch your verification, then remove the old one from your configuration: once the rotation is over, the new secret is checked against X-CHARI-SIGNATURE. Rehearse it in the sandbox before doing it in production.
Is the webhook secret the same in the sandbox and in production?
No. Each endpoint belongs to a single environment — through the API, that of the key that registered it — and gets its own secret: the sandbox secret never verifies a production delivery. When you go live, register a new endpoint with your production key and store its secret separately: the key and the webhook secret are the only two things that change.
What if I have lost my signing secret?
The secret is only shown at creation and at rotation: no read returns it. Start a rotation from the portal or through the API to get a new secret, shown once. During the rotation, verify X-CHARI-SIGNATURE-NEXT with that new secret, then X-CHARI-SIGNATURE once the rotation is over, and this time store it in a secrets vault, next to your API key.
Next step
Register a sandbox endpoint behind an HTTPS tunnel, send yourself the test event and read the response in the log: Start in test mode gives you the key you need. Then write the receiver with getting your webhook integration right, and run a real sandbox payment with testing payments in test mode.
Written by ChariPay team.