Why the webhook is the critical link
A payment webhook is the server-to-server call the provider (Orange Money, Wave, Stripe) sends to your backend when a payment changes status. It, not the browser redirect, is authoritative. A poorly secured webhook opens the door to fake payment confirmations and duplicate processing.
This guide covers the four pillars of a robust webhook: signature verification, idempotency, correct HTTP response, and retry handling.
Anatomy of a webhook payload
A webhook arrives via a POST request to your endpoint, with a JSON body and signature headers.
| Field | Type | Description |
|---|---|---|
| event_id | string | Unique event identifier, the idempotency key |
| event_type | string | Type, for example payment.succeeded |
| order_id | string | Order reference on the merchant side |
| amount | integer | Amount in FCFA |
| status | string | SUCCESS, FAILED, PENDING |
| created_at | timestamp | Event timestamp |
| signature | string | HMAC of the body, in the header |
Headers to read
The signature travels in a dedicated header, often alongside a timestamp to block replay attacks.
| Header | Role |
|---|---|
| X-Signature | HMAC SHA-256 of the raw body |
| X-Timestamp | Send timestamp, for anti-replay window |
| Content-Type | application/json |
HMAC signature verification
The signature proves the webhook truly comes from the provider and the body was not altered. The computation uses the raw body, never the re-serialized JSON.
- Read the raw request body, byte for byte.
- Read the X-Signature and X-Timestamp headers.
- Compute HMAC SHA-256 of the body with your webhook secret.
- Compare the computed signature to the received one in constant time.
- Reject if the timestamp is older than 5 minutes (anti-replay).
Classic mistake
Many bugs come from a framework parsing the JSON before you read the raw body. Re-serializing changes whitespace and breaks the HMAC. Always read the raw body before any parsing.
Idempotency: process exactly once
Providers resend the same webhook several times (retries). Your logic must be idempotent: processing an event_id twice must never charge twice nor deliver twice.
| Strategy | Mechanism | Benefit |
|---|---|---|
| Unique key in database | Unique index on event_id | Immediate rejection of duplicates |
| Per-order lock | Lock on order_id during processing | Avoids race conditions |
| Transactional status | Atomic PENDING to PAID transition | No double processing |
The simplest and most robust: a unique index on event_id. If the insert fails due to a duplicate, return 200 without reprocessing.
HTTP response and status codes
What you return decides whether the provider retries or not. A wrong response triggers needless retries or, worse, loses the event.
| Returned code | Provider interpretation | When to use it |
|---|---|---|
| 200 | Received and processed | Success, or already-processed duplicate |
| 400 | Invalid request | Invalid signature, corrupted payload |
| 401 | Unauthenticated | Missing or wrong signature |
| 409 | Conflict | Processing already in progress (optional) |
| 500 | Server error | Internal bug, triggers a retry |
Need a professional website?
Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.
Return 200 as quickly as possible, then process in the background if the work is long. A webhook that responds in more than a few seconds risks a timeout on the provider side.
Retry strategy
If you respond with a 5xx error or do not respond, the provider retries on an exponential backoff.
| Attempt | Indicative delay | Backend action |
|---|---|---|
| 1 | immediate | Process or log the failure |
| 2 | about 1 minute | Check idempotency |
| 3 | about 5 minutes | Check idempotency |
| 4 | about 30 minutes | Alert support if still failing |
| 5 and beyond | up to 24 h | Manual reconciliation |
Concrete example
A customer pays a 40,000 FCFA order. The provider sends payment.succeeded. Your server reads the raw body, verifies the HMAC, checks that event_id is not in the database, inserts the event, moves the order from PENDING to PAID in a transaction, then returns 200. One minute later a duplicate retry arrives with the same event_id: the insert fails on the unique index, you return 200 immediately, no double delivery. Total: one effective processing, zero double charge.
Webhook security checklist
| Control | Expected status |
|---|---|
| HMAC verification on raw body | mandatory |
| Constant-time signature comparison | mandatory |
| Anti-replay window on the timestamp | mandatory |
| Unique index on event_id | mandatory |
| HTTPS-only endpoint | mandatory |
| Fast 200 response, async processing | recommended |
| Logging of every event | recommended |
FAQ
Why not rely on the browser redirect?
Because the customer may close the tab or lose connectivity before returning. Only the server-to-server webhook guarantees you are notified of the real status.
What content should the HMAC be computed on?
On the raw request body, byte for byte, before any JSON parsing. Re-serializing the JSON changes whitespace and invalidates the signature.
How do I avoid double processing?
A unique index on event_id in the database. If the event already exists, return 200 without reprocessing. It is the simplest and most reliable method.
What should I return if the signature is invalid?
A 401 or 400 code, without processing the event. Never return 200 for a webhook whose signature does not match.
How long does a provider retry?
Usually up to 24 hours with exponential backoff. After total failure, plan a manual reconciliation or a replay endpoint.
Let's talk about your project. Kolonell builds secure payment backends with signature verification and idempotency by default. WhatsApp +221 77 596 93 33.
Mohamed Bah
Fondateur, Kolonell
Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.

