# 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](https://speedmap-delta.vercel.app/docs/response-shaping) |
| `bulk.completed` | Every row of a [bulk job](https://speedmap-delta.vercel.app/docs/bulk) has been processed. | The bulk job, with final counts |
| `consent.updated` | A [consent](https://speedmap-delta.vercel.app/docs/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:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | No | Unique event id, `evt_…`. Use it to deduplicate. |
| `type` | string | No | The event name, e.g. `verification.completed`. |
| `mode` | string | No | `sandbox` or `live`. |
| `created_at` | string | No | ISO 8601 timestamp. |
| `data` | object | No | The event object. |

**verification.completed**

```json
{
  "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**

```json
{
  "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:

```http
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.

_Node.js_

```javascript
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);
});
```

_Python_

```python
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SPEEDMAP_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300

def verify_speedmap_signature(raw_body: bytes, header: str, secret: str) -> bool:
    # Header: Speedmap-Signature: t=<unix seconds>,v1=<hex>
    try:
        parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
        t = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > TOLERANCE_SECONDS:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

@app.post("/webhooks/speedmap")
def speedmap_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_speedmap_signature(raw, request.headers.get("Speedmap-Signature"), SECRET):
        abort(400)
    event = json.loads(raw)
    enqueue(event)  # deduplicate on event["id"]
    return "", 200
```

## 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`.

---

Source: https://speedmap-delta.vercel.app/docs/webhooks
