Webhooks
Instead of polling, register an endpoint and OKIAS pushes each verification decision to you the instant it resolves. Every event is signed with HMAC-SHA256 so you can verify it came from OKIAS.
Register a URL, receive signed events, verify the signature, then acknowledge with a 2xx. Failed deliveries are retried with backoff, durably — a restart on our side never loses a delivery.
Register an endpoint
Add endpoints from Dashboard → Webhooks, which maps to an authenticated API:
/v1/webhooks/v1/webhooks/v1/webhooks/:id/v1/webhooks/:id/rotate-secret/v1/webhooks/:id/test/v1/webhooks/:idcurl https://api.okias.io/v1/webhooks \
-H "Authorization: Bearer <dashboard-session>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/okias",
"events": ["verification.approved", "verification.declined", "verification.review", "verification.error"]
}'The response returns the signing secret once:
{
"id": "cms7yv2ab0007oea2h3k9d2fq",
"url": "https://yourapp.com/webhooks/okias",
"events": ["verification.approved", "verification.declined", "verification.review", "verification.error"],
"secret": "whsec_9c1e2b7f0d5a1a10...", // shown ONCE — store it now
"active": true,
"created_at": "2026-07-30T10:00:00.000Z"
}whsec_… secret is shown a single time. Save it to your environment as OKIAS_WEBHOOK_SECRET. Lost it? Rotate the secret from the dashboard (or POST /v1/webhooks/:id/rotate-secret).POST /v1/webhooks/:id/test (or the “Send test” button in the dashboard) to fire a webhook.test event and confirm your endpoint verifies signatures correctly.Event types
| Event | Fires when |
|---|---|
| verification.approved | A verification resolved as approved. |
| verification.review | A verification was routed to manual review. |
| verification.declined | A verification resolved as declined. |
| verification.error | Processing could not complete; the credit hold is released. |
| webhook.test | A test event you triggered from the dashboard / test endpoint. |
events array you send at registration is validated and echoed back, but it is not yet a stored subscription filter — an active endpoint currently receives all of the events above. Branch on X-Okias-Event (or event in the body) and ignore the types you do not handle, still answering 2xx so they are not retried.Event payload & headers
Every delivery is a JSON body plus three OKIAS headers:
POST /webhooks/okias
Content-Type: application/json
User-Agent: OKIAS-Webhooks/1.0
X-Okias-Event: verification.approved
X-Okias-Delivery: cms80niu2002boea2f4q7wxhs
X-Okias-Signature: t=1785405600,v1=6f1c...e2a9
{
"id": "cms80niu2002boea2f4q7wxhs",
"event": "verification.approved",
"created": 1785405600,
"data": {
"id": "cms80k9ix001moea2jxy64fgu",
"status": "APPROVED",
"risk_score": 8,
"reason_codes": []
}
}| Header | Description |
|---|---|
| X-Okias-Event | The event type, e.g. verification.approved. |
| X-Okias-Delivery | Unique delivery id — use it for idempotent processing / replay protection. |
| X-Okias-Signature | The signature: t=<unix-seconds>,v1=<hex-hmac>. |
For verification.* events, data carries the verification id, status, risk_score and reason_codes. Reconcile via the id you stored at create time; for the full record (including end_user_ref and the owner-only detail fields), fetch GET /v1/verifications/:id.
Verify the signature
The signing scheme is deliberately simple:
- Take the timestamp
tfrom theX-Okias-Signatureheader. - Compute
signed = "{t}." + rawBody— the timestamp, a dot, then the exact raw request body. - HMAC-SHA256 that string with your endpoint's
whsec_secret and hex-encode it. - Constant-time compare it to
v1.
Copy-paste verification (Node & Python)
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60; // reject events older than 5 minutes
// Verify an OKIAS webhook. Pass the RAW request body (exact bytes/string
// received) — re-serialised JSON will not match the signature.
export function verifyOkiasWebhook(rawBody, signatureHeader, secret) {
// Header: "t=<unix-seconds>,v1=<hex-hmac>"
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=")),
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) return false;
// 1 · Replay protection — reject stale timestamps.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
// 2 · Recompute HMAC-SHA256 over `${t}.${rawBody}`.
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// 3 · Constant-time comparison.
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the raw body so the signature matches.
import express from "express";
const app = express();
app.post(
"/webhooks/okias",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
const ok = verifyOkiasWebhook(
raw,
req.header("X-Okias-Signature"),
process.env.OKIAS_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(raw);
// Handle event.event / event.data, then acknowledge fast.
res.status(200).send("ok");
},
);Retries & backoff
A delivery succeeds only when your endpoint returns a 2xx within the timeout. Anything else — a non-2xx status, a timeout, a connection error — is retried up to 34 attempts total, spread over roughly 24 hours, after which the delivery is marked failed and kept. The first nine attempts back off exponentially (1s, 2s, 4s … 256s, about 8.5 minutes in all) so a transient blip costs nothing; after that we retry hourly, because past a few minutes the cause is a deploy or an outage rather than a blip. A handler that is down for an hour still receives its events.
Retries are durable: every delivery, its payload and its next attempt time live in our database, not in a process’s memory, so a deploy or restart on our side never drops an in-flight delivery. Each attempt is also signed at the moment it is sent, so the t value in X-Okias-Signature is always fresh — a retry never arrives with a stale timestamp against your tolerance window.
| Property | Value |
|---|---|
| Success condition | Any 2xx response |
| Attempts | 34 (the first delivery plus 33 retries), over ~24 hours |
| Per-attempt timeout | 8 seconds |
| Backoff between attempts | 1s · 2s · 4s · 8s · 16s · 32s · 64s · 128s · 256s · then hourly |
| Terminal states | DELIVERED or FAILED |
- Return
200quickly — do heavy work asynchronously after acknowledging. - A slow handler counts as a failure: the attempt is aborted at 8 seconds and retried.
- Non-2xx responses (including a
4xxyou return from a failed signature check) trigger a retry. - Actual retry timing is the backoff plus a few seconds of scheduling latency — retries are driven by a sweep, not a timer held in memory.
Deliveries survive restarts
Every delivery is a durable record, not an in-memory job: the event body, the attempt counter and the time the delivery is next due are all persisted before the first attempt is made. If the process running an attempt dies mid-flight, the delivery becomes due again and another process resumes it from the next attempt — you do not lose the event, and you do not get an unbounded retry storm either, because a crashed attempt still consumes one of the five.
- The delivery id is stable across attempts. All five attempts of one event carry the same
X-Okias-Delivery— which is exactly what makes it safe to de-duplicate on. - The signature timestamp is per attempt. Each retry is re-signed with the current time, so a retry that lands minutes later is still fresh against your 5-minute tolerance check. Never widen your tolerance to accommodate retries.
- Terminal deliveries are never resent. Once a delivery is
DELIVEREDorFAILEDit is finished; only OKIAS support can re-arm a failed delivery.
INVALID_WEBHOOK_URL.Inspect delivery history
GET /v1/webhooks/:id (dashboard session) returns the 20 most recent deliveries for an endpoint under recent_deliveries, each with status, attempts, response_code, next_attempt_at and last_error — the fastest way to see whether OKIAS sent an event, what your endpoint answered, and when the next retry is due.
GET /v1/verifications/:id for anything that matters — treat the webhook as the low-latency notification and the API as the authority.Replay protection & idempotency
- Reject stale timestamps. Compare
tto your clock and reject events older than a tolerance (5 minutes is typical) to defeat replays. - De-duplicate on delivery id. Because retries — and support-initiated replays of failed deliveries — can deliver the same event more than once, record
X-Okias-Deliveryand ignore ids you have already processed. Make your handler idempotent.