This page takes you from nothing to a verified delivery. It uses curl and a
small Node receiver, but nothing here is Node-specific.
Check which events you can subscribe to
The catalogue is the authority, and it is readable before you have registered anything:
curl -s https://api.nyumbazetu.com/v3/webhooks/events \ -H "Authorization: Bearer $NZ_TOKEN"{ "success": true, "data": [ { "eventType": "billing.invoice.issued", "version": 1, "means": "An invoice was issued to its payer and is now collectable.", "entityType": "invoice" } ] }Registering an event name that is not on this list is refused at registration rather than accepted and silently never delivered. Requires permission 4801 (
WebhooksView).Stand up a receiver that keeps the raw body
Your endpoint must be reachable over HTTPS on a public address, and it must be able to see the request body as the exact bytes that were sent. In Express that means mounting a raw body parser on the webhook route —
express.json()parses and discards those bytes, and the signature can then never verify.const express = require("express"); const crypto = require("crypto"); const app = express(); const SECRET = process.env.NZ_WEBHOOK_SECRET; // RAW body on this route. Not express.json(). app.post( "/nyumbazetu", express.raw({ type: "application/json" }), (req, res) => { const ok = verify(req.body, req.headers, SECRET); if (!ok) return res.status(400).send("bad signature"); // 2xx first, work afterwards. res.status(200).send("ok"); const event = JSON.parse(req.body.toString("utf8")); console.log(event.event, event.executionId); } ); function verify(rawBody, headers, secret, toleranceSec = 300) { const ts = headers["x-nz-webhook-timestamp"]; const sig = headers["x-nz-webhook-signature"]; if (!ts || !sig) return false; if (!/^\d+$/.test(String(ts))) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > toleranceSec) return false; const expected = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`, "utf8").digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } app.listen(3000);Verifying signatures explains every line of
verify, and has Python and PHP equivalents.Register the endpoint
curl -s -X POST https://api.nyumbazetu.com/v3/webhooks \ -H "Authorization: Bearer $NZ_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Ops notifier", "description": "Payments and invoices into our ops queue", "url": "https://hooks.example.com/nyumbazetu", "eventTypes": ["cash.payment.received", "billing.invoice.issued"] }'{ "success": true, "data": { "uuid": "9f1c7e5a-3b2d-4f80-8c11-6a2d9e4b7c03", "name": "Ops notifier", "description": "Payments and invoices into our ops queue", "url": "https://hooks.example.com/nyumbazetu", "eventTypes": ["cash.payment.received", "billing.invoice.issued"], "enabled": true, "branchId": null, "lastTriggeredAt": null, "lastSuccessAt": null, "lastFailureAt": null, "totalAttempts": 0, "totalSuccesses": 0, "totalFailures": 0, "createdAt": "2026-09-30T09:12:44.000Z", "signingSecret": "4f2c…64 hex characters…b7e1", "subscriptions": 2 }, "message": "Created. Store the signing secret now — it cannot be shown again." }The organisation comes from your token, never the body. Requires permission 4802 (
WebhooksCreate).Fire a test event
This sends a synthetic event through the real delivery path — same signing, same address checks, same delivery log — so a pass here means a real delivery will pass too.
curl -s -X POST \ https://api.nyumbazetu.com/v3/webhooks/9f1c7e5a-3b2d-4f80-8c11-6a2d9e4b7c03/test \ -H "Authorization: Bearer $NZ_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "eventType": "cash.payment.received" }'{ "success": true, "data": { "delivered": true, "statusCode": 200, "durationMs": 412, "error": null } }eventTypeis optional and defaults tocash.payment.received. Requires permission 4803 (WebhooksEdit).Confirm what the platform saw
curl -s "https://api.nyumbazetu.com/v3/webhooks/9f1c7e5a-3b2d-4f80-8c11-6a2d9e4b7c03/executions?limit=10" \ -H "Authorization: Bearer $NZ_TOKEN"Each row carries the event type, the URL, the response status, the duration and any error message. Sensitive request headers — including the signature itself — are stored redacted, so this log can be read without exposing a live key. Requires permission 4801 (
WebhooksView).
You now have
- An endpoint receiving signed deliveries for two events.
- A verified signature, checked against the raw body inside a 300-second window.
- A delivery log you can read when something looks wrong.