Skip to content

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.

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.

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.

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 "*".

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.

verify.js (Node 18+)
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);
}
Express handler
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.

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.

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.

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.

  • 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 data as a snapshot, not an instruction. Fetch the resource if you need current state.
  • Expect collection.expired to arrive on a read, not exactly at expiry.