Webhooks
Nuansis sends webhooks when a verification or collection changes state. Deliveries are signed with a per-endpoint secret, retried a small number of times, and recorded in a delivery log.
Webhook endpoints are managed from the dashboard (Settings → Webhooks) using your
signed-in session. They are not part of the /v1 API-key surface, so a nvs_… key cannot
create or list endpoints.
Register an endpoint
Section titled “Register an endpoint”In the dashboard, add an endpoint with:
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | Yes | Absolute http(s) URL, maximum 512 characters |
events |
string[] | No | Event names to subscribe to. Omit or send [] for ["*"] (all events) |
active |
boolean | No | Defaults to true |
The response includes the signing secret exactly once:
{ "id": 6, "url": "https://example.com/webhooks/nuansis", "events": ["verification.verified", "collection.successful"], "active": true, "has_secret": true, "created_at": "2026-10-18T10:26:07.412Z", "secret": "9f2c1a7b4e6d8c0a1b3f5e7d9c2a4b6f8e0d1c3b5a7f9e2d4c6b8a0f1e3d5c7b"}Store the secret in your secret manager. It is 64 hex characters and is never returned by the list or update endpoints; if it is lost, delete the endpoint and create a new one. There is no secret-rotation action.
Under the hood the dashboard calls POST /webhook-endpoints (JWT). The same controller exposes
GET /webhook-endpoints, PUT /webhook-endpoints/:id (URL, events, active only), and
DELETE /webhook-endpoints/:id.
Delivery format
Section titled “Delivery format”Each event is a POST with a JSON body:
{ "id": "5b1c9d3e-7a2f-4c8b-9e1d-3f5a7c9b2d4e", "event": "verification.verified", "created_at": "2026-10-18T10:31:07.412Z", "data": { "reference": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "channel": "sms", "destination": "+2348****678", "status": "verified", "attempts": 1, "verified_at": "2026-10-18T10:31:05.002Z" }}Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Nuansis-Event |
Event name, e.g. verification.verified |
X-Nuansis-Delivery |
Delivery UUID, identical to the body’s id |
X-Nuansis-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
The same delivery UUID is reused across retries, so it is the correct key for consumer-side de-duplication.
Events
Section titled “Events”| Event | Fired when | data |
|---|---|---|
verification.verified |
A code is accepted | reference, channel, destination (masked), status, attempts, verified_at |
verification.failed |
Attempts are exhausted | Same as above plus reason (invalid_code or too_many_attempts) |
verification.expired |
A check is attempted after the code’s TTL has passed | Same as above, status: "expired" |
collection.successful |
A bank transfer is matched to a one-off collection | id, reference, amount, currency, account_number, novac_reference |
collection.expired |
A pending collection is read after expiry | id, reference, amount, account_number |
reserved_account.created |
A reserved account is created | id, reference, account_number, bank, customer |
ping |
A test is triggered from the dashboard | message, endpoint_id |
An endpoint receives an event if its events array contains the exact name or "*".
Verifying a signature
Section titled “Verifying a signature”The signed string is <t>.<raw body> — the decimal timestamp, a literal dot, and the exact bytes
received. The digest is HMAC-SHA256 with the endpoint secret, hex-encoded, and is sent as v1:
X-Nuansis-Signature: t=1760789467,v1=4f2c…Verification requires the raw request body. If your framework parses JSON first and you re-stringify it, key order and whitespace will differ and the signature will not match.
import crypto from 'node:crypto';
/** * @param {Buffer|string} rawBody Exact bytes received, before JSON parsing. * @param {string} signatureHeader Value of X-Nuansis-Signature. * @param {string} secret Endpoint secret from creation. * @param {number} toleranceSec Reject timestamps older than this. */export function verifyNuansisSignature(rawBody, signatureHeader, secret, toleranceSec = 300) { const parts = Object.fromEntries( String(signatureHeader) .split(',') .map((part) => part.split('=')), );
const timestamp = Number(parts.t); if (!Number.isFinite(timestamp) || !parts.v1) return false;
// Replay protection: the sender does not enforce a window, so the receiver should. if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex');
const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1, 'hex'); return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b);}import express from 'express';import { verifyNuansisSignature } from './verify.js';
const app = express();
// Raw body for this route only — do not use express.json() here.app.post('/webhooks/nuansis', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyNuansisSignature( req.body, req.get('X-Nuansis-Signature'), process.env.NUANSIS_WEBHOOK_SECRET, ); if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge first; process asynchronously in production. res.sendStatus(200);
// De-duplicate on the delivery id — retries reuse it. handleEventOnce(event.id, event.event, event.data);});Return any 2xx to acknowledge. Any other final response is treated as a failure and retried.
Retries and timeouts
Section titled “Retries and timeouts”| Setting | Value |
|---|---|
| Attempts | 3 total |
| Delays before attempts | 0 ms, 500 ms, 1500 ms |
| Per-attempt timeout | 10 seconds |
| Retry trigger | Network error, timeout, or any non-2xx response |
| Automatic redelivery after the third failure | None |
The response body is not inspected. A 200 with an error inside is still a success from the
delivery system’s point of view.
After three failed attempts the delivery is logged as failed and no further attempts are made. There is no dashboard “replay” action; use the test ping to validate an endpoint after fixing it.
Delivery log
Section titled “Delivery log”Every dispatch writes one row — not one per attempt — with the final outcome. View it in the dashboard, or through the JWT-authenticated endpoint:
GET /webhook-deliveries?endpoint_id=<id>&limit=50
| Field | Meaning |
|---|---|
endpointId |
Endpoint the delivery targeted |
event |
Event name |
payload |
Full JSON body that was sent (id, event, created_at, data) |
statusCode |
Last HTTP status received, or null on a network error |
attempts |
Attempts made (1–3) |
ok |
1 on success, 0 on failure |
error |
Last error text, e.g. HTTP 500 |
createdAt |
When the dispatch was recorded |
limit defaults to 50 and is clamped to 200. Results are newest first. Rows are scoped to endpoints
belonging to the signed-in workspace.
Test ping
Section titled “Test ping”From the dashboard, Send test dispatches a ping event to the endpoint and returns the delivery
result synchronously:
{ "delivery_id": 418, "endpoint_id": 6, "ok": true, "status_code": 200, "attempts": 1, "error": null}Under the hood this is POST /webhook-endpoints/:id/test (JWT). delivery_id is the log row id, or
0 if the row could not be persisted; ok reflects whether your endpoint returned a 2xx.
Use it after deploying a handler to confirm both reachability and signature verification.
Consumer checklist
Section titled “Consumer checklist”- Verify the signature against the raw body before trusting any field.
- Reject timestamps outside a small tolerance window (5 minutes is typical).
- De-duplicate on
X-Nuansis-Delivery; retries reuse the id. - Acknowledge quickly and process asynchronously — the timeout is 10 seconds.
- Treat
dataas a snapshot, not an instruction. Fetch the resource if you need current state. - Expect
collection.expiredto arrive on a read, not exactly at expiry.