Skip to content

Webhooks

Receive signed events when verifications complete, bulk jobs finish and consents change, instead of polling.

View as Markdown

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

EventFires whendata
verification.completedA verification reaches verified, not_found or failed, including a pending check that resolves later.The verification assertion
bulk.completedEvery row of a bulk job has been processed.The bulk job, with final counts
consent.updatedA consent request is granted or denied.The consent object
webhook.testYou click Send test event in the dashboard.{ "message": "Speedmap test event" }

Payload

Every event has the same envelope:

  • idstring

    Unique event id, evt_…. Use it to deduplicate.

  • typestring

    The event name, e.g. verification.completed.

  • modestring

    sandbox or live.

  • created_atstring

    ISO 8601 timestamp.

  • dataobject

    The event object.

verification.completed
{
  "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"
  }
}
consent.updated
{
  "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:

  1. Split the header on , and read t and v1.
  2. Reject the event if t is more than a few minutes from your clock (we suggest 5 minutes). This stops replays.
  3. Compute HMAC-SHA256 of t, a literal ., and the raw request body, exactly as received.
  4. Compare it with v1 using 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 2xx within 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 use created_at or 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.