Skip to main content

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 typeFired whenKey payload fields
ocr.completedA POST /v1/ocr/scan request returns 2xxocr_id, document_type (cid | passport), fields (extracted), portrait (signed-URL + strategy + confidence), image_quality, mode
face.verify.completedA POST /v1/face/verify request returns 2xxmatch (bool), similarity (0–1), threshold, image_a/image_b quality, model_version, mode
liveness.check.completedA POST /v1/liveness/check request returns 2xxlc_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-parser with json() followed by JSON.stringify(req.body), Gin's c.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:

AttemptDelay from previous
1(initial)
230 seconds
32 minutes
410 minutes
51 hour
66 hours
724 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:

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.