Developers · Webhooks

Webhooks guide

NunuaPay notifies your server when a payment becomes successful or failed. Verify every delivery with the signature header, process it idempotently, and never treat an unsigned callback as proof of payment.

Two kinds of webhook

  • Inbound provider webhooks — Paystack, PayHero and M‑Pesa call NunuaPay. You never handle these; NunuaPay verifies and reconciles them.
  • Outbound merchant webhooks — NunuaPay calls your endpoint after it has established the outcome. These are the ones you handle.

Create an endpoint

  1. POST /v1/webhook-endpoints with your HTTPS URL and the events you want.
  2. Store the returned signing secret securely — it is shown once.
  3. Use POST /v1/webhook-endpoints/:id/test to send a test delivery.

Events

NunuaPay currently emits payment.succeeded and payment.failed to merchant endpoints. Subscribe only to the events you need.

Verify the signature

Each delivery includes X-NunuaPay-Signature: t=<timestamp>,v1=<HMAC-SHA256>, plus X-NunuaPay-Event-Id and X-NunuaPay-Timestamp. The signed value is HMAC-SHA256(secret, "<t>.<raw body>").

// Express-style handler (Node.js)
import crypto from "node:crypto";

app.post("/webhooks/nunuapay", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-NunuaPay-Signature") || "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", process.env.NUNUAPAY_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body.toString("utf8")}`)
    .digest("hex");

  const ok = crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.status(400).json({ error: "invalid_signature" });

  const event = JSON.parse(req.body.toString("utf8"));
  // Process idempotently by event.id, then acknowledge quickly.
  res.json({ received: true });
});

Design an idempotent handler

  • Key your processing on X-NunuaPay-Event-Id so a retried delivery is a no-op.
  • Reject stale timestamps to protect against replay, and compare signatures in constant time.
  • Acknowledge with a 2xx promptly; do heavy work asynchronously.
  • Only fulfil an order after confirming payment.succeeded — re-verify with the API if in doubt.

Retries and backoff

Deliveries are retried up to 8 times with exponential backoff (capped at 60 seconds) until your endpoint returns a 2xx. Return 2xx even if you decide to ignore an event, so NunuaPay stops retrying.

Handling pending and failed payments

STK-push payments can remain pending while the customer acts on their phone. Do not fulfil on pending; wait for payment.succeeded. On payment.failed, mark the attempt failed, surface a retry to the customer, and reuse the same Idempotency-Key rather than minting a new request.

Related