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
POST /v1/webhook-endpointswith your HTTPS URL and the events you want.- Store the returned signing secret securely — it is shown once.
- Use
POST /v1/webhook-endpoints/:id/testto 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-Idso 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.