Webhooks
Signed session events, sent to the account’s own endpoints
Get started with webhooks
When a verification session’s decision is set or changes, or it reopens, expires, is abandoned or is canceled, Hira sends a signed HTTPS POST to every endpoint the account registered for that event type. The body names the session and nothing else; the receiver reads the session through the API.
Endpoints
Endpoints are registered in the console under Administration › Developer › Webhooks › New endpoint, on live and sandbox accounts alike: a URL, a description, the Events to send (at least one) and the API version 2026-10-01. The API has no door for them. An account holds at most 10 endpoints, disabled ones included. An endpoint is created enabled, and its signing secret, hira_whsec_…, is shown once in the Copy the signing secret dialog. A sandbox account’s endpoints receive its events with livemode false.
The URL is https on port 443, with no user name, password or fragment, at most 2048 characters, and not under a Hira domain. Redirects are never followed. Each address the name resolves to is checked at create, at a URL change, at a test send, at a resend and at every attempt, and private and special-purpose addresses are refused. Creating an endpoint, enabling it, changing its URL and rolling its secret notify the account’s administrators and owners.
Event types
| type | Sent when |
|---|---|
| verification_session.decided | A decision is set or changes; an in_review session later approved sends a second one. |
| verification_session.reopened | A completed, expired or abandoned session opens again: a new link, a late step, an officer’s request for more, or a retry decline. |
| verification_session.expired | A session that never started passes its deadline. |
| verification_session.abandoned | A started session passes its deadline. |
| verification_session.canceled | A session is canceled. |
An endpoint receives only the types ticked on it; there is no wildcard. A session moving to error sends no event.
Envelope and seq
The body is thin: ids and a sequence number, no personal data and no status. It is the same bytes on every attempt and every resend.
{
"id": "evt_…",
"object": "event",
"type": "verification_session.decided",
"apiVersion": "2026-10-01",
"createdAt": "2026-10-12T04:19:03.481220Z",
"livemode": false,
"data": { "id": "vsn_8f3a…", "object": "verification_session", "seq": 1 }
}
data.seq is the session’s change counter at this event; a later change of the same session has a greater one, and the session object read through the API carries the same counter as seq. Deliveries are not ordered and may arrive more than once, so the receiver keeps one rule: dedupe on id, drop an event whose data.seq is below the last one applied for that session, and take the session’s state from GET /v1/verification-sessions/{id}.
Signature
Each delivery carries Content-Type: application/json, User-Agent: Hira-Webhooks/1.0, Hira-Event-Id (the body’s id) and:
Hira-Signature: t=1760242743,v1=5f2b…
t is the attempt’s unix time in seconds; each v1 is the hex HMAC-SHA256, keyed by the signing secret, over t, a dot and the raw body. Each attempt carries a fresh t and signature. After Roll secret, both secrets sign for 24 hours and the header carries two v1 values, so the receiver can switch secrets at any point that day.
constructEvent(rawBody, header, secret, { tolerance, now }) from @hira/sdk verifies a delivery with Web Crypto and no API key, and answers the event. It throws HiraSignatureError, whose reason is malformed_header, no_signature, bad_signature, timestamp_outside_tolerance or bad_tolerance. The tolerance is how far t may sit from the receiver’s clock: 1 to 600 seconds, 300 unless set, and it cannot be turned off. now sets the clock it is held to, a function answering milliseconds since the epoch, Date.now unless set; a test passes a fixed clock. Hira’s sending addresses are not published, so a delivery is trusted by its signature alone.
Retries and auto-disable
An attempt counts when the endpoint answers 2xx within 10 seconds. A 3xx, any other status, a timeout or a refused address is a failed attempt. A live account’s delivery is tried at once, then after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h and 24 h three times: ten attempts over about 92 h 36 m, each wait varying by up to 10 percent. Then the delivery reads failed. A sandbox account’s delivery is tried three times within an hour.
An endpoint with seven days of nothing but failures is disabled with a reason, and the account’s administrators and owners are told. The register shows an endpoint as Disabled with its reason, or Failing since the first failure of its current run.
Test send and resend
Send test event posts a delivery of the first type the endpoint receives and shows the receiver’s answer. A test carries Hira-Test-Webhook: true, an id starting evt_test_ and a data.id starting vsn_test_ that names no session. The delivery drawer lists each endpoint’s deliveries, and Resend on a concluded delivery sends the same body again. Test sends and resends share one window of ten a minute per endpoint; the eleventh answers “Too many test sends or resends. Wait a minute and try again.” Delivered rows are kept 30 days and failed rows 90.
Example
import express from "express"; import { Hira, constructEvent, HiraSignatureError } from "@hira/sdk"; const hira = new Hira({ apiKey: process.env.HIRA_API_KEY, endpoint: process.env.HIRA_ENDPOINT }); const secret = process.env.HIRA_WEBHOOK_SECRET; // hira_whsec_…, shown once const seen = new Set(); // event ids applied const appliedSeq = new Map(); // session id to the highest seq applied const app = express(); app.post("/hira/webhooks", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = await constructEvent(req.body, req.header("Hira-Signature") ?? "", secret); } catch (err) { return res.sendStatus(err instanceof HiraSignatureError && err.reason !== "bad_tolerance" ? 400 : 500); } res.sendStatus(200); // acknowledge first, then work try { if (seen.has(event.id) || event.data.id.startsWith("vsn_test_")) return; seen.add(event.id); if (event.data.seq < (appliedSeq.get(event.data.id) ?? 0)) return; const s = await hira.verificationSessions.get(event.data.id); appliedSeq.set(s.id, Math.max(appliedSeq.get(s.id) ?? 0, s.seq)); console.log(event.type, s.customerRef, s.status, s.decision); } catch (err) { console.error(err); } }); app.listen(3000);
express.raw keeps the bytes that were signed; a body re-serialised by a JSON middleware does not verify. The example app runs this receiver beside the routes that create and simulate sessions.