Webhooks
The platform delivers async events to HTTPS endpoints you register, so your backend can react to verification completions without polling. Webhooks are live today; configure endpoints from the Dashboard → Webhooks page.
This page covers the protocol — what your endpoint will receive and what it must do. For the operator UI walkthrough (registering URLs, rotating secrets, re-enabling disabled endpoints), see the dashboard page linked above.
Event types
| Event type | Fired when | Key payload fields |
|---|---|---|
ocr.completed | A POST /v1/ocr/scan request returns 2xx | ocr_id, document_type (cid | passport), fields (extracted), portrait (signed-URL + strategy + confidence), image_quality, mode |
face.verify.completed | A POST /v1/face/verify request returns 2xx | match (bool), similarity (0–1), threshold, image_a/image_b quality, model_version, mode |
liveness.check.completed | A POST /v1/liveness/check request returns 2xx | lc_id, is_live (bool), score, threshold, mode, completed_challenges (for active liveness), request_id of the original call |
Only successful API calls fire webhooks. 4xx and 5xx responses don't — your client already knows the call failed; a duplicate notification adds no value.
Delivery format
POST https://your-endpoint
Content-Type: application/json
Drukverify-Webhook-Id: wh_evt_01HF...
Drukverify-Webhook-Timestamp: 1778632003
Drukverify-Webhook-Signature: t=1778632003,v1=<hex-hmac>
{
"id": "wh_evt_01HF...",
"type": "ocr.completed",
"created_at": "2026-05-13T00:34:15Z",
"mode": "live",
"tenant_id": "019e18a6-58de-77e4-b46c-48ae74045f88",
"data": { ...event-specific payload... }
}
The signature header follows the Stripe convention so existing verification libraries work with minor tweaks.
Signature verification
Every endpoint registered on the dashboard gets its own signing secret (whsec_…) which is shown exactly once at creation time in the dashboard. The platform signs every delivery with HMAC-SHA256 over {timestamp}.{rawBody}. Verify server-side before trusting the body.
Node.js
const crypto = require('crypto');
function verify(req, secret) {
const ts = req.headers['drukverify-webhook-timestamp'];
const sig = req.headers['drukverify-webhook-signature']
.split(',').find(s => s.startsWith('v1=')).slice(3);
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex'),
)) {
throw new Error('signature mismatch');
}
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error('stale timestamp');
}
}
Go
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func Verify(headers map[string]string, body []byte, secret string) error {
ts := headers["Drukverify-Webhook-Timestamp"]
sigHeader := headers["Drukverify-Webhook-Signature"]
var sig string
for _, p := range strings.Split(sigHeader, ",") {
if strings.HasPrefix(p, "v1=") {
sig = strings.TrimPrefix(p, "v1=")
}
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.%s", ts, body)
expected := hex.EncodeToString(mac.Sum(nil))
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
return fmt.Errorf("signature mismatch")
}
tsNum, _ := strconv.ParseInt(ts, 10, 64)
if d := time.Since(time.Unix(tsNum, 0)); d > 5*time.Minute || d < -5*time.Minute {
return fmt.Errorf("stale timestamp")
}
return nil
}
Two non-obvious things both languages share:
- Use the raw request body. Anything that re-serializes JSON (Express's
body-parserwithjson()followed byJSON.stringify(req.body), Gin'sc.BindJSON, etc.) reorders keys and breaks the signature. Capture the raw bytes before parsing. - Reject replays older than 5 minutes. The platform retries with exponential backoff for up to 24 hours, so a legitimate retry will always be recent. Anything older is either an attacker replaying captured payloads or a buggy retry loop on your side — drop either way.
Retry policy
A delivery is considered successful when your endpoint returns any 2xx status within 10 seconds. Anything else (3xx, 4xx, 5xx, timeout, connection error) is a failure, and the dispatcher retries with exponential backoff:
| Attempt | Delay from previous |
|---|---|
| 1 | (initial) |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
After the 7th failure the event is moved to a dead-letter queue, visible on the endpoint's detail page in the dashboard. Your endpoint is automatically disabled if it accumulates too many consecutive failures in a short window — see the dashboard for the "Re-enable" action.
Idempotency on your end
Webhook delivery is at-least-once. The retry policy above means the same wh_evt_… id can land at your endpoint more than once (a 200 response that we never received, a transient network blip mid-ACK, a re-enabled endpoint flushing a backlog, etc.). Make your handler idempotent:
async function handleWebhook(event) {
// De-dupe by the event id. If we've already processed this id, skip.
const seen = await redis.set(`wh:${event.id}`, '1', 'EX', 86400, 'NX');
if (!seen) {
return; // already handled this exact delivery
}
// ...do real work...
}
A 24-hour TTL covers the worst-case retry tail.
Endpoint requirements
- HTTPS only.
http://URLs are rejected at registration time. - Respond within 10 seconds. Do not synchronously do real work in the handler — accept the event, enqueue it, return 200 immediately.
- Return 200 even if you intentionally skip the event (e.g. the type isn't one you handle). Returning 4xx will trigger a retry and eventually disable the endpoint.
- Accept multiple modes per URL via separate registrations. A given endpoint row is either live OR test mode; register the same URL twice (one per mode) to receive both streams.
Polling fallback
If you'd rather poll than handle webhooks — or use both for belt-and-braces — every webhook event has a corresponding read endpoint you can hit later with the id from the original API call:
GET /v1/ocr/{ocr_id}GET /v1/liveness/checks/{lc_id}- For face verify there's no dedicated read endpoint — the response is fully delivered on the original POST.
Polling counts against your usage caps the same way the original call does; webhooks don't bill at all. For high-volume integrations, webhooks are strictly cheaper.