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.re_reviewed, subject_check.verdict_overridden, batch_check.completed, monitoring.alert, monitoring.verdict_changed, monitoring.risk_changed, monitoring.status_changed, 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.re_reviewed: correction-based stableevent_id, numericaccount_id,subject_id, andsubject_check_id,previous_verdictandresulting_verdictstrings,reviewed_at(ISO 8601 string), anddecision_contextwith numericcorrection_id, numericanalyst_id, and the recordedreason. For monitored checks,subject_idis the monitoring subscription’s original subject; for standalone checks it falls back to the Person Check ID.subject_check.verdict_overridden: correction-based stableevent_id, numeric tenantaccount_id, numericsubject_idandsubject_check_id, distinctprevious_verdictandreplacement_verdictstrings,overridden_at(ISO 8601 string), andoverride_contextwith numericcorrection_id, numericanalyst_id, and the recordedreason. For monitored checks,subject_idis the monitoring subscription’s original subject; for standalone checks it falls back to the Person Check ID.batch_check.completed: numeric tenantaccount_id, stablebatch_id,status("completed"when every item completed,"failed"when every item failed, or"partial"for a mixture), total/completed/failed counts, input-indexedsubjects, a separatefailuresarray,partial_completioncounts/details, and one persisted ISO 8601completed_at. Each subject has itssubject_check_idplus a verdict or failure detail.monitoring.alert:event_id(UUID string), numericaccount_id,monitoring_subscription_id(UUID string), numeric originalsubject_id, numeric currentsubject_check_id, asubjectidentity object,signal_type("sanctions","pep", or"adverse_media"), non-emptymatched_source_details,detected_at(ISO 8601 string), andrisk_context.monitoring.verdict_changed:event_id(UUID string), numericaccount_id,monitoring_subscription_id(UUID string), numeric originalsubject_id, numeric currentsubject_check_id, distinctprevious_verdictandcurrent_verdictstrings,changed_at(ISO 8601 string), anddecision_contextwith previous/current decision versions, confidence levels, and finding counts.monitoring.risk_changed:event_id(UUID string), numericaccount_id,monitoring_subscription_id(UUID string), numeric originalsubject_id, numeric currentsubject_check_id, asubjectidentity object, non-emptychanged_fieldswith previous/current values,current_verdict(string), andchanged_at(ISO 8601 string).monitoring.status_changed:event_id(UUID string), numericaccount_id,monitoring_subscription_id(UUID string), numeric originalsubject_id, numericsubject_check_id, distinctprevious_statusandcurrent_statusvalues ("active"or"paused"), andchanged_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, reviewed_at, overridden_at, detected_at, changed_at (including watch status transitions), retried_at, and delivered_at. The batch batch_id, retry payload’s previous_delivery_id, and monitoring payloads’ event_id and monitoring_subscription_id are UUID strings. Correction events use event_id values stable for their correction; the test subject fields are strings as shown below.
event value selects the payload shape. Lifecycle events (processing, review_required, escalated, completed, and failed) share numeric subject_check_id and tenant account_id fields plus a stage-specific status. Correction events and monitoring events carry their own subject identifiers and stable event_id values; batch completion uses batch_id, and retry carries the check ID with retry-specific fields. Both correction events are flat signed JSON payloads with a shared top-level envelope: event, event_id, account_id, subject_id, subject_check_id, and previous_verdict. A correction event’s event_id is derived from the correction_id in its event-specific context and stays stable across delivery retries. Overrides add replacement_verdict, overridden_at, and override_context; re-reviews add resulting_verdict, reviewed_at, and decision_context, with resulting_verdict equal to previous_verdict. Timestamps are event-specific. Result fields, review counts and source coverage, escalation fields, retry fields, and the test subject are specific to their event. investigation_created_at and sla_due_at are conditional escalation fields: accept them when present and do not require them in every escalation. Monitoring events are tenant-scoped to their account and watch; event_id plus monitoring_subscription_id and subject_check_id are stable receiver idempotency inputs. Correction event IDs remain stable across delivery retries. 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 account API response to a test-send action may include delivery_id and attempted_at; those response metadata fields are not part of the signed webhook.test callback body. 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": "batch_check.completed",
"account_id": 678,
"batch_id": "4a3f7c1e-5f47-4d79-a7aa-8f1e2b7c9d10",
"status": "partial",
"total_count": 3,
"completed_count": 2,
"failed_count": 1,
"subjects": [
{ "input_index": 0, "subject_check_id": 12345, "verdict": "clear", "confidence_level": "high", "findings_count": 0 },
{ "input_index": 1, "subject_check_id": 12346, "verdict": "review", "confidence_level": "medium", "findings_count": 2 },
{ "input_index": 2, "subject_check_id": null, "verdict": null, "failure": { "code": "invalid_payload", "message": "first_name is required" } }
],
"failures": [{ "input_index": 2, "subject_check_id": null, "code": "invalid_payload", "message": "first_name is required" }],
"partial_completion": { "total_count": 3, "completed_count": 2, "failed_count": 1, "pending_count": 0, "details": [{ "input_index": 2, "subject_check_id": null, "code": "invalid_payload", "message": "first_name is required" }] },
"completed_at": "2026-08-30T12:02:05.000Z"
}
{
"event": "monitoring.alert",
"event_id": "f0c5b6d5-6e90-4e0a-9e65-8c3b4c2d7e10",
"account_id": 678,
"monitoring_subscription_id": "2f1a4c58-9d2e-4b5f-83a8-1f0d4c7b9e20",
"subject_id": 12001,
"subject_check_id": 12346,
"subject": {
"name": "John Doe",
"first_name": "John",
"middle_name": null,
"last_name": "Doe",
"dob": "1980-01-01"
},
"signal_type": "adverse_media",
"matched_source_details": [{
"headline": "Regulator opens investigation into Example Co",
"category": "regulatory_action",
"date": "2026-08-29",
"source_url": "https://news.example.test/business/investigation",
"sources": ["Example News"],
"score": 0.91
}],
"detected_at": "2026-08-30T12:07:00.000Z",
"risk_context": {
"overall_result": "adverse_found",
"confidence_level": "high",
"findings_count": 3,
"red_flag_count": 1,
"warning_count": 2,
"tier_6": {
"adverse_media": {
"found": true,
"vendor_status": "ran",
"subject_gate": "passed",
"match_count": 1,
"matches": [{ "headline": "Regulator opens investigation into Example Co", "category": "regulatory_action", "date": "2026-08-29", "source_url": "https://news.example.test/business/investigation", "sources": ["Example News"] }]
}
},
"tier_7": {
"compliance_flags": { "sanctions_hit": true, "pep_hit": false },
"retrieved_at": "2026-08-30T12:06:58.000Z"
}
}
}
{
"event": "monitoring.verdict_changed",
"event_id": "d49f80f7-2b8d-4c9a-a86c-32c092394ea1",
"account_id": 678,
"monitoring_subscription_id": "2f1a4c58-9d2e-4b5f-83a8-1f0d4c7b9e20",
"subject_id": 12001,
"subject_check_id": 12347,
"previous_verdict": "clear",
"current_verdict": "adverse_found",
"changed_at": "2026-08-30T12:08:00.000Z",
"decision_context": {
"previous": {
"decision_version": "person-check-v1",
"confidence_level": "high",
"findings_count": 0,
"red_flag_count": 0,
"warning_count": 0
},
"current": {
"decision_version": "person-check-v2",
"confidence_level": "high",
"findings_count": 3,
"red_flag_count": 1,
"warning_count": 2
}
}
}
{
"event": "monitoring.risk_changed",
"event_id": "a5c2079a-1f6d-4bb8-9ac9-6c605e9c4e23",
"account_id": 678,
"monitoring_subscription_id": "2f1a4c58-9d2e-4b5f-83a8-1f0d4c7b9e20",
"subject_id": 12001,
"subject_check_id": 12348,
"subject": {
"name": "John Doe",
"first_name": "John",
"middle_name": null,
"last_name": "Doe",
"dob": "1980-01-01"
},
"changed_fields": {
"findings_count": { "previous": 2, "current": 3 },
"pep_hit": { "previous": false, "current": true }
},
"current_verdict": "clear",
"changed_at": "2026-08-30T12:09:00.000Z"
}
{
"event": "monitoring.status_changed",
"event_id": "e5407d4f-6ed1-47f7-9e8a-3a5ed2f18a22",
"account_id": 678,
"monitoring_subscription_id": "2f1a4c58-9d2e-4b5f-83a8-1f0d4c7b9e20",
"subject_id": 12001,
"subject_check_id": 12001,
"previous_status": "active",
"current_status": "paused",
"changed_at": "2026-08-30T12:10:00.000Z"
}
{
"event": "subject_check.verdict_overridden",
"event_id": "subject_check.verdict_overridden:9001",
"account_id": 678,
"subject_id": 4321,
"subject_check_id": 12349,
"previous_verdict": "accepted",
"replacement_verdict": "rejected",
"overridden_at": "2026-08-30T12:05:45.000Z",
"override_context": {
"correction_id": 9001,
"analyst_id": 77,
"reason": "New evidence changed the disposition"
}
}
{
"event": "subject_check.re_reviewed",
"event_id": "subject_check.re_reviewed:9002",
"account_id": 678,
"subject_id": 4321,
"subject_check_id": 12349,
"previous_verdict": "accepted",
"resulting_verdict": "accepted",
"reviewed_at": "2026-08-30T12:05:45.000Z",
"decision_context": {
"correction_id": 9002,
"analyst_id": 77,
"reason": "No disposition change"
}
}
{
"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"
}
}
subject_check.verdict_overridden is emitted when a saved analyst correction changes the effective verdict. Its event-specific fields are replacement_verdict, overridden_at, and override_context, which contains the correction ID, analyst ID, and recorded reason. It uses the Person Check owner’s tenant account and an event_id in the format subject_check.verdict_overridden:<correction_id>. For a monitored check, subject_id is its monitoring subscription’s original subject; for a standalone check, it is the Person Check ID. Receivers can use the stable correction-based ID to deduplicate retries.
subject_check.re_reviewed is emitted when a saved analyst correction leaves the effective verdict unchanged, so resulting_verdict equals previous_verdict. Its event-specific fields are resulting_verdict, reviewed_at, and decision_context, which contains the correction ID, analyst ID, and recorded reason. It uses the Person Check owner’s tenant account and an event_id in the format subject_check.re_reviewed:<correction_id>. Subscribe to both correction event types to receive every saved correction; receivers can use each stable correction-based ID to deduplicate retries.
monitoring.verdict_changed event is emitted when a completed poll changes the verdict from that baseline or the immediately previous completed poll; unchanged or missing verdicts do not emit one. A monitoring.risk_changed event is emitted once per watch and completed check when any of overall_result, confidence_level, findings_count, red_flag_count, warning_count, Tier 7 sanctions_hit, pep_hit, or list_breakdown changes from the baseline or immediately previous completed check. Nested JSON fields are compared structurally, ignoring key order and volatile retrieval/provenance metadata. A risk event can be emitted while current_verdict stays the same; changed_fields contains only changed values as { "previous": ..., "current": ... }. Its changed_at timestamp is the current check’s completion time. These three screening monitoring event types are tenant-scoped and use the same HMAC signing, bounded retry, and per-attempt webhook_deliveries history as lifecycle events. The outbox keeps the same event_id and signed body for later delivery. 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, overridden_at, reviewed_at, detected_at, changed_at, retried_at, or delivered_at field.
monitoring.status_changed is emitted only when the persisted watch state changes between active and paused. The payload identifies both the original subject and the monitoring subscription. Its event_id, fields, and changed_at value are persisted in the outbox, so bounded retries and later monitoring-pass retries retain the same event ID and signed body; each HTTP attempt is recorded in webhook_deliveries.
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, overridden_at, reviewed_at, detected_at, changed_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; formonitoring.alert,monitoring.verdict_changed,monitoring.risk_changed, andmonitoring.status_changed, use the signedevent_idtogether withmonitoring_subscription_idandsubject_check_id; forsubject_check.verdict_overriddenandsubject_check.re_reviewed, deduplicate by their stable correction-basedevent_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.