Webhooks
Receive signed events when verifications complete, bulk jobs finish and consents change, instead of polling.
Speedmap sends an HTTPS POST to your endpoint when something you care about happens. Add endpoints in Dashboard → Webhooks, choose the events, and copy the signing secret (whsec_…), shown once when the endpoint is created.
Endpoints belong to one mode: a sandbox endpoint receives only sandbox events. Live endpoints must use https; sandbox endpoints may use http to a public host while you integrate.
Events
| Event | Fires when | data |
|---|---|---|
verification.completed | A verification reaches verified, not_found or failed, including a pending check that resolves later. | The verification assertion |
bulk.completed | Every row of a bulk job has been processed. | The bulk job, with final counts |
consent.updated | A consent request is granted or denied. | The consent object |
webhook.test | You click Send test event in the dashboard. | { "message": "Speedmap test event" } |
Payload
Every event has the same envelope:
idstringUnique event id,
evt_…. Use it to deduplicate.typestringThe event name, e.g.
verification.completed.modestringsandboxorlive.created_atstringISO 8601 timestamp.
dataobjectThe event object.
{
"id": "evt_9b1f2c3d4e5f6a7b",
"type": "verification.completed",
"mode": "sandbox",
"created_at": "2026-10-19T09:41:07.874Z",
"data": {
"reference_id": "spm_8171f3a87cbe221341f7185f",
"object": "verification",
"product": "nin",
"method": "nin_raw",
"status": "verified",
"match": true,
"field_matches": {
"last_name": true
},
"field_confidence": {
"last_name": 100
},
"mode": "sandbox",
"reason": "financialProducts",
"subject": "*******2109",
"summary": {
"first_name": "A*****",
"last_name": "O*****"
},
"consent": {
"mechanism": "client_attested",
"consent_id": null,
"timestamp": "2026-10-19T09:41:07.512Z"
},
"source": "api",
"error": null,
"billing": {
"amount_kobo": 0,
"currency": "NGN"
},
"latency_ms": 412,
"created_at": "2026-10-19T09:41:07.512Z",
"updated_at": "2026-10-19T09:41:07.874Z"
}
}{
"id": "evt_0a1b2c3d4e5f6a7b",
"type": "consent.updated",
"mode": "sandbox",
"created_at": "2026-10-19T09:43:52.004Z",
"data": {
"id": "cns_423a8f143e7f2f8ffe7dda3344d36710",
"object": "consent",
"identifier_type": "bvn",
"subject": "*******2221",
"categories": "YYNN",
"category_labels": [
"Personal identification",
"Contact information"
],
"channel": "ussd",
"status": "granted",
"reason": "financialProducts",
"authorization_url": "https://speedmap-delta.vercel.app/consent/cns_423a8f143e7f2f8ffe7dda3344d36710",
"ussd_code": "*565*1*7*70205378#",
"redirect_url": null,
"granted_at": "2026-10-19T09:43:52.004Z",
"expires_at": "2026-10-20T09:43:52.004Z",
"created_at": "2026-10-19T09:41:07.512Z",
"mode": "sandbox"
}
}Assertions only
Webhook payloads never contain the identity record (data on the verification is absent, whatever your tier). If you are entitled to the full record, fetch it with GET /v1/verifications/{reference_id} from your backend.
Verifying signatures
Every request carries a Speedmap-Signature header:
Speedmap-Signature: t=1792403667,v1=5f0c2a8d7e4b…t is the Unix time of signing and v1 is the hex HMAC-SHA256 of the string ${t}.${rawBody}, keyed with your endpoint's whsec_ secret. To verify:
- Split the header on
,and readtandv1. - Reject the event if
tis more than a few minutes from your clock (we suggest 5 minutes). This stops replays. - Compute HMAC-SHA256 of
t, a literal., and the raw request body, exactly as received. - Compare it with
v1using a constant-time comparison.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SPEEDMAP_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verifySpeedmapSignature(rawBody, header, secret) {
// Header: Speedmap-Signature: t=<unix seconds>,v1=<hex>
const parts = Object.fromEntries(
String(header ?? "").split(",").map((kv) => kv.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the raw body: re-serialized JSON will not match the signature.
app.post("/webhooks/speedmap", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifySpeedmapSignature(raw, req.get("Speedmap-Signature"), SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(raw);
// Acknowledge fast; do the work asynchronously. Deduplicate on event.id.
res.sendStatus(200);
queue.push(event);
});Delivery and retries
- Respond with any
2xxwithin 10 seconds. Anything else, including a redirect, counts as a failure; redirects are never followed. - Failed deliveries are retried: four attempts in total, at 0 s, 5 s, 30 s and 120 s. After the last attempt the delivery is marked failed in the dashboard.
- Events can arrive more than once and out of order. Deduplicate on
id, and usecreated_ator the object's own status to decide what is newest. - Acknowledge first, then process asynchronously, so slow work does not cause retries.
Requests come with Content-Type: application/json and User-Agent: Speedmap-Webhooks/1.0. The dashboard keeps the latest deliveries for each endpoint, with response code, attempts and last error.
Testing
Use Send test event on an endpoint in the dashboard to deliver a signed webhook.test event. In the sandbox, any check fires a real verification.completed, and approving a consent with POST /v1/sandbox/consents/{id}/approve fires consent.updated.
Questions? Email developers@speedmap.ng. This page is also available as Markdown.