Webhooks
The source of truth for your integration: every state change is pushed to you, signed, with automatic redelivery.
Create an endpoint
POST /v1/webhooks
{
"url": "https://yoursite.com/webhook/neopays",
"description": "production",
"subscribed_events": ["payment.success", "refund.success"]
}
The response carries the signing secret. An empty subscribed_events means every event; an
event outside the catalogue is refused rather than stored, because it would wait forever for a
delivery nothing emits.
This call ensures, it does not always create. A URL already registered comes back at
200instead of being duplicated — a provisioning script run twice leaves ONE endpoint, not two receiving every event in duplicate. The secret is returned either way, because the legitimate reason to call again is having lost it.
GET /v1/webhooks — your endpoints and their counters
GET /v1/webhooks/{id} — one endpoint
PATCH /v1/webhooks/{id} — partial update
DELETE /v1/webhooks/{id} — permanent removal
GET /v1/webhooks/{id}/secret — read the secret back
POST /v1/webhooks/test — a test delivery to every active endpoint
PATCH changes only what you send: omitting is_active does not deactivate the endpoint. To stop
deliveries without losing the endpoint or its secret, send is_active: false rather than deleting.
Ten endpoints per account. The dashboard does all of this too, and only it can replay a failed delivery and rotate a secret.
The events
| Event | When |
|---|---|
payment.initiated |
Collection created |
payment.pending |
Awaiting the payer's confirmation |
payment.success |
Collected — the event that counts |
payment.failed |
Failure |
payout.success / payout.failed / payout.cancelled |
A payout's life cycle |
payout.refunded |
A payout returned by the rail afterwards |
refund.success / refund.failed |
A refund's life cycle |
The envelope is always the same:
{
"event": "payment.success",
"created": "2026-08-24T12:00:00Z",
"data": {
"id": "op_01J8Z9K2QW",
"reference": "NP-...",
"merchant_reference": "order-1042",
"amount": "5000",
"currency": "XOF",
"status": "success"
}
}
The webhook body does not have the same shape as the API response. It carries
amountandcurrencywhere the API carriesamount_minorandasset_id, andmerchant_referencewhere it carriesclient_ref. It is a separate contract, written earlier and unchanged since: do not assume an object from one decodes as an object from the other.
Verify the signature
Every delivery carries the header:
X-NeoPays-Signature: t=<unix timestamp>,v1=<hmac>
where hmac = HMAC-SHA256(secret, "<t>.<raw body>"), in hexadecimal. Verify in three steps:
const crypto = require('node:crypto')
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
The RAW body, not the re-serialised one. Compute the HMAC over the bytes you received, before any JSON parsing. A re-
JSON.stringifycan reorder keys and invalidate the signature.
During a secret rotation the header carries TWO
v1=entries, the current secret first. Check them all, not just the first: the day you switch your configuration to the new secret before the platform-side rotation completes, the first signature is the old secret's, and a verifier that stops at the first then rejects 100% of deliveries.
Redelivery
Answer 2xx within a few seconds (acknowledge, then process). Any other answer starts the retry
ladder: 5 attempts in total, after 60 s, 5 min, 30 min and 2 h.
An endpoint that fails 20 consecutive deliveries is suspended for an hour (deliveries resume on their own); a single success — including a test delivery from the dashboard — reinstates it immediately.
Idempotency on your side
Deliveries are at-least-once: the same event can arrive twice. Deduplicate on the pair
(event, data.id), or log the event identifiers you have processed.