neo-pays

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 200 instead 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 amount and currency where the API carries amount_minor and asset_id, and merchant_reference where it carries client_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.stringify can 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.