1. Set up an HTTPS callback
Register one callback URL for the account with POST /api/v1/compliance/webhooks. The call requires the account bearer token and an HTTPS URL. Veridact returns a randomly generated signing_secret; keep it in server-side secret storage and never expose or log it.
curl -X POST https://veridact.solutions/api/v1/compliance/webhooks \ -H "Authorization: Bearer vda_your_token_here" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/veridact/webhook"}'
{
"success": true,
"id": 42,
"url": "https://example.com/veridact/webhook",
"signing_secret": "a4f2...",
"created_at": "2026-08-30T12:00:00.000Z"
}
2. Verify the raw request bytes
Veridact computes HMAC-SHA256(signing_secret, raw_body) and sends the lowercase hexadecimal digest as X-Veridact-Signature: sha256=<hex>. Preserve the raw UTF-8 bytes, calculate the digest in that order, compare with a constant-time function, and only then parse JSON. A missing or mismatched signature should be rejected before parsing or processing.
// webhook-receiver.js — drop-in for a Node/Express app const express = require('express'); const crypto = require('crypto'); const app = express(); const signingSecret = process.env.VERIDACT_WEBHOOK_SECRET; const REPLAY_WINDOW_MS = 5 * 60 * 1000; const seenEvents = new Map(); function verifySignature(rawBody, signatureHeader) { const expected = 'sha256=' + crypto .createHmac('sha256', signingSecret) .update(rawBody) // Buffer: exact bytes received .digest('hex'); const received = Buffer.from(signatureHeader || '', 'utf8'); const expectedBytes = Buffer.from(expected, 'utf8'); return received.length === expectedBytes.length && crypto.timingSafeEqual(received, expectedBytes); } function replayKey(event) { const attemptKey = event.event === 'subject_check.retry' ? `:${event.previous_delivery_id || event.attempt_number}` : ''; return event.event === 'webhook.test' ? `webhook.test:${event.subject?.id}` : `${event.event}:${event.subject_check_id}${attemptKey}`; } // This is receiver-side duplicate protection, not a new signing input. // Mount this route before any express.json() middleware. app.post('/veridact/webhook', express.raw({ type: 'application/json' }), (req, res) => { if (!verifySignature(req.body, req.headers['x-veridact-signature'])) { return res.status(400).json({ ok: false, reason: 'signature_mismatch' }); } let event; try { event = JSON.parse(req.body.toString('utf8')); } catch (error) { return res.status(400).json({ ok: false, reason: 'invalid_json' }); } const now = Date.now(); for (const [key, seenAt] of seenEvents) { if (now - seenAt >= REPLAY_WINDOW_MS) seenEvents.delete(key); } const key = replayKey(event); if (seenEvents.has(key)) { return res.status(409).json({ ok: false, reason: 'replay_detected' }); } seenEvents.set(key, now); // Production: replace this Map with durable idempotency storage, then // persist or enqueue the verified event before acknowledging it. res.status(200).json({ ok: true, replay: false }); }); app.listen(3000);
These fixtures use the documentation-only secret example-signing-secret. Copy the body bytes exactly when recomputing each digest.
Secret: example-signing-secret Body: {"event":"webhook.test","delivered_at":"2026-08-30T12:06:00.000Z","subject":{"id":"test_subject","name":"Webhook Test Event","first_name":"Test","last_name":"Event"}} X-Veridact-Signature: sha256=13b1aca37fbaa46bed7dfd93e3cb74d73e858bfaa79b68a4b6bb8e6ffcfe2483 Verification: HMAC passes; parse and process the event.
{ ok: true, replay: false }
Body: {"event":"webhook.test","delivered_at":"2026-08-30T12:00:00.000Z","subject":{"id":"test_subject","name":"Webhook Test Event","first_name":"Test","last_name":"Event"}} X-Veridact-Signature: sha256=070749ccef2422f9362dcef673f6529f67ea7fb489a041deab4a14e7b67203a1 Verification: HMAC passes; the receiver’s local freshness/replay policy rejects it before business processing.
{ ok: false, reason: 'timestamp_expired' }
X-Veridact-Timestamp header, the event-specific delivered_at value is not included in the HMAC input, and Veridact does not server-enforce timestamp expiry. Event timestamps remain informational; enforce freshness only if your receiver chooses to.
Body: {"event":"webhook.test","delivered_at":"2026-08-30T12:06:00.000Z","subject":{"id":"test_subject","name":"Webhook Test Event","first_name":"Test","last_name":"Event"}} X-Veridact-Signature: sha256=13b1aca37fbaa46bed7dfd93e3cb74d73e858bfaa79b68a4b6bb8e6ffcfe2484 Verification: HMAC fails; reject before JSON parsing or processing.
{ ok: false, reason: 'signature_mismatch' }
200 with { ok: true, replay: false }; the same verified event within the illustrative five-minute window returns 409 with reason: 'replay_detected'. The window is local duplicate protection only: do not add a timestamp to the HMAC formula or expect an X-Veridact-Timestamp header. completed_at, failed_at, delivered_at, and test-event timestamps are informational fields, not freshness proofs. Every JSON byte in the delivered body is covered by the signature, so re-serializing parsed JSON or changing whitespace produces a different digest. In production, replace the in-memory map with durable idempotency storage and persist or enqueue the verified event before returning 2xx. API signing reference → · Node recipe → · Webhook endpoint reference →
3. Payload reference
The signed callback registered with POST /api/v1/compliance/webhooks sends JSON whose top-level event field is always present and is the discriminator. Branch on that value to handle subject_check.processing, subject_check.review_required, subject_check.escalated, subject_check.completed, subject_check.failed, subject_check.retry (a manual “Retry now”), or webhook.test (an operator test delivery). Lifecycle callbacks are emitted for checks created by both the single and batch Compliance API endpoints. There is no nested data envelope and no generic top-level timestamp; every field shown here is part of the signed JSON body.
Required fields. event is required on every payload. The following fields are required for their respective event:
subject_check.processing:subject_check_id,account_id,status(the string"running"), andprocessing_at(an ISO 8601 string).subject_check.review_required:subject_check_id,account_id,status(the string"needs_review"),overall_result,confidence_level, finding counts,review_reason, andreview_required_at(an ISO 8601 string).subject_check.escalated:subject_check_id,account_id,status(the string"escalated"),investigation_id,triggering_tiers,tier_scores,requested_scope,priority, andescalated_at(an ISO 8601 string).subject_check.completed:subject_check_id(number),account_id(number),status(the string"completed"),overall_result(string),confidence_level(string),findings_count(number), andcompleted_at(ISO 8601 string).subject_check.failed:subject_check_id(number),account_id(number),status(the string"failed"),error_message(string), andfailed_at(ISO 8601 string).subject_check.retry:subject_check_id(number),retried_at(ISO 8601 string),previous_delivery_id(delivery UUID string), andattempt_number(number). The same check ID is retained for routing and deduplication.webhook.test:delivered_at(ISO 8601 string) and the requiredsubjectobject containingid,name,first_name, andlast_name(all strings).
Live check IDs, account IDs, finding counts, and attempt numbers are numeric. Event timestamps are ISO 8601 strings: processing_at, review_required_at, escalated_at, completed_at, failed_at, retried_at, and delivered_at. The retry payload’s previous_delivery_id is a delivery UUID string; the test subject fields are strings as shown below.
event, numeric subject_check_id, numeric tenant account_id, and the stage-specific status stable across its processing, review, escalation, completion, and failure branches. Result fields, review counts and source coverage, escalation fields, retry fields, the test subject, and each event-specific timestamp are branch-specific. investigation_created_at and sla_due_at are conditional escalation fields: accept them when present and do not require them in every escalation. Ignore additive unknown fields so receivers remain forward-compatible. webhook.test is the intentional exception: it has a string-valued subject object and no live check ID.
Content-Type: application/json User-Agent: Veridact-Webhooks/1.0 X-Veridact-Signature: sha256=<hex>
previous_delivery_id and attempt_number are body fields only on subject_check.retry. Ordinary deliveries do not expose a generic delivery_id or attempt counter in the payload. The X-Veridact-Signature covers the exact raw body bytes, including whitespace and encoding, before JSON parsing.
{
"event": "subject_check.processing",
"subject_check_id": 12345,
"account_id": 678,
"status": "running",
"processing_at": "2026-08-30T12:00:01.000Z"
}
{
"event": "subject_check.review_required",
"subject_check_id": 12345,
"account_id": 678,
"status": "needs_review",
"overall_result": "inconclusive",
"confidence_level": "low",
"findings_count": 0,
"red_flag_count": 0,
"warning_count": 0,
"sources_searched": 2,
"sources_total": 8,
"review_reason": "insufficient_source_coverage",
"review_required_at": "2026-08-30T12:00:45.000Z"
}
{
"event": "subject_check.escalated",
"subject_check_id": 12345,
"account_id": 678,
"status": "escalated",
"investigation_id": "9f2a...",
"triggering_tiers": ["tier_5", "tier_7"],
"tier_scores": { "tier_5": 91, "tier_7": 86 },
"requested_scope": "Verify the adverse media match",
"priority": "critical",
"investigation_created_at": "2026-08-30T12:02:00.000Z",
"sla_due_at": "2026-08-31T12:02:00.000Z",
"escalated_at": "2026-08-30T12:02:00.000Z"
}
{
"event": "subject_check.completed",
"subject_check_id": 12345,
"account_id": 678,
"status": "completed",
"overall_result": "clean",
"confidence_level": "high",
"findings_count": 0,
"completed_at": "2026-08-30T12:01:05.000Z"
}
{
"event": "subject_check.failed",
"subject_check_id": 12345,
"account_id": 678,
"status": "failed",
"error_message": "Screening provider unavailable",
"failed_at": "2026-08-30T12:01:05.000Z"
}
{
"event": "subject_check.retry",
"subject_check_id": 12345,
"retried_at": "2026-08-30T12:05:00.000Z",
"previous_delivery_id": "d7f6a8b1-2d4c-4f7e-9a10-123456789abc",
"attempt_number": 2
}
{
"event": "webhook.test",
"delivered_at": "2026-08-30T12:06:00.000Z",
"subject": {
"id": "test_subject",
"name": "Webhook Test Event",
"first_name": "Test",
"last_name": "Event"
}
}
webhook.test intentionally has no subject_check_id; it uses the required subject object instead. There is no generic top-level timestamp in this signed payload contract, so use the event-specific completed_at, failed_at, retried_at, or delivered_at field.
4. Design for replay and idempotency
The current X-Veridact-Signature protocol has no timestamp header or server-enforced replay expiry. The completed_at, failed_at, retried_at, and delivered_at fields are informational event timestamps, not a freshness proof. A valid signature proves that the bytes were signed with your secret; it does not prove that the request is new.
- Durably accept only events that pass signature verification.
- Deduplicate live deliveries by event type plus
subject_check_id; forsubject_check.retry, includeprevious_delivery_id(or another stable attempt-specific field) when separate retry events must remain distinct. - Use event-specific handling for
webhook.testpayloads, which do not have a live check ID. - Return a
2xxonly after the verified event is durably accepted or safely queued.
// Signature verification has already passed; webhook_events has UNIQUE (idempotency_key). const subjectKey = event.event === 'webhook.test' ? event.subject.id : event.subject_check_id; const attemptKey = event.event === 'subject_check.retry' ? (event.previous_delivery_id || event.attempt_number) : ''; const idempotencyKey = [event.event, subjectKey, attemptKey].join(':'); const accepted = await db.query(` INSERT INTO webhook_events (idempotency_key, event_type, subject_key, payload) VALUES ($1, $2, $3, $4) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id `, [idempotencyKey, event.event, String(subjectKey), event]); if (!accepted.rows.length) { return res.status(200).json({ ok: true, duplicate: true }); } await queue.enqueue(accepted.rows[0].id); // durable queueing before acknowledgement return res.status(202).json({ ok: true });
5. Delivery guarantees and retries
Delivery runs fire-and-forget after a lifecycle transition, so an unreachable callback does not block the subject-check response. Each outbound request has a 10-second timeout. All lifecycle events use the same HMAC signing and at most three total delivery attempts:
- Attempt 1: immediate.
- Attempt 2: 1 second after the first attempt.
- Attempt 3: 5 seconds after the second attempt.
A 2xx response succeeds immediately. Non-408/429 4xx responses are terminal. Transport failures, 5xx responses, 408, and 429 are retried until the three-attempt limit is reached.
6. Delivery timing, ordering, and idempotency
Each live request has a 10-second timeout and at most three total attempts: attempt 1 is immediate, attempt 2 is 1 second later, and attempt 3 is another 5 seconds later. Transport failures, 5xx, 408, and 429 responses are retried; other 4xx responses are terminal.
The same signed event can be delivered more than once. Return a 2xx only after durable acceptance or queueing, and acknowledge a duplicate without repeating its business effects. The consumer example in the replay section shows this conflict-safe boundary.
Delivery order is not guaranteed. Veridact does not promise a global event order, including across a batch. HTTP arrival order and event timestamps are informational; do not use them as a global sequence. Persist events, key handling to the event plus subject_check_id, and make state transitions tolerant of events arriving out of order. For distinct retry events, include previous_delivery_id or another stable attempt-specific key. webhook.test has no live subject_check_id and uses its event-specific subject.id instead.
7. Production endpoint checklist
- Register the endpoint. Call
POST /api/v1/compliance/webhookswith the account bearer token, use an HTTPS callback, and store the returnedsigning_secretserver-side. - Preserve the raw body. Keep the exact request bytes by mounting the raw-body parser before JSON parsing.
- Verify before processing. Check
X-Veridact-Signaturewith HMAC-SHA256 and a constant-time comparison; reject missing or invalid signatures before parsing or processing. - Enforce replay protection. Use a durable idempotency key and receiver-side replay window. The current protocol has no timestamp header, and event timestamps are not freshness proofs; give
webhook.testits own key because it has no live check ID. - Acknowledge safely. Return success only after durable acceptance or safe queueing, handle duplicates idempotently, and follow the retry contract: three live attempts, retrying transport errors,
5xx,408, and429; other4xxresponses are terminal.
For delivery timing, duplicate handling, and out-of-order events, see the delivery ordering and idempotent consumer guidance. For adjacent integration details, see the API signing reference, copy-paste Node recipe, and webhook setup endpoint.