NuruPay Docs

Webhooks

Get notified when a payment succeeds, fails, or expires, and verify it came from NuruPay.

NuruPay sends an HTTPS POST to your webhook endpoint when a collection reaches a final state. During the pilot, NuruPay registers your endpoint and gives you its signing secret (whsec_…, shown once).

Events

EventWhen
collection.succeededThe customer paid.
collection.failedDeclined, insufficient funds, cancelled, or rejected; see data.failure_code.
collection.expiredNot approved in time; the order was cancelled.

Payload

{
  "id": "evt_01M3P5FBS9V5FJ1P5R0N8Z64XX",
  "type": "collection.succeeded",
  "created_at": "2026-09-29T08:46:05Z",
  "mode": "live",
  "data": { "id": "col_…", "object": "collection", "status": "succeeded", "amount": 1000, "...": "..." }
}

data is the full collection object at the moment of the event.

Verify the signature

Every webhook carries a NuruPay-Signature header:

NuruPay-Signature: t=1759132800,v1=c57e6ec5e76a0cdd68ee3fd1eba420ff9abf04cd9755d174d59e1c4d6e926a54

v1 is the hex HMAC-SHA256 of "<t>.<raw request body>" using your signing secret. Always verify it before trusting the body, and reject timestamps older than five minutes to stop replays.

Verify against the raw bytes you received. Parsing the JSON and serialising it again changes the bytes and breaks the signature.

import crypto from 'node:crypto';

// Returns true only if the webhook is genuine and fresh.
// rawBody must be the exact bytes received (do not re-serialise JSON).
export function verifyNuruPaySignature(rawBody, header, secret, toleranceSeconds = 300, now = Date.now()) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(now / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Respond quickly

  • Return any 2xx within 10 seconds, then do slow work (sending emails, updating stock) in the background.
  • Anything else, including timeouts and redirects, counts as a failure. NuruPay retries after 1m, 5m, 30m, 2h, 6h, and 12h, then marks the delivery failed.
  • An endpoint that fails continuously for three days is disabled; NuruPay re-enables it once you have fixed it.

Handle duplicates and ordering

  • Delivery is at least once: you may receive the same event twice. Store the event id and ignore repeats.
  • Events can arrive out of order. If order matters, fetch the collection with GET /v1/collections/{id} for its current state.
  • To double-check an event, fetch it with GET /v1/events/{id} using your API key.

Endpoint requirements

  • Live-mode endpoints must use https://.
  • Endpoints must be publicly reachable; private and internal addresses are refused.

On this page