Webhooks

Verify every delivery before you process it

Set up a callback, verify the exact request bytes with HMAC-SHA256, make handling idempotent, and build around the delivery retry contract.

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"}'
Response 200
{
  "success": true,
  "id": 42,
  "url": "https://example.com/veridact/webhook",
  "signing_secret": "a4f2...",
  "created_at": "2026-08-30T12:00:00.000Z"
}
Rotation. Re-registering or updating the callback URL rotates the secret. Deploy the new secret before switching traffic, then treat the old secret as invalid for subsequent deliveries. Setup endpoint reference →

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);
Compare verification outcomes

These fixtures use the documentation-only secret example-signing-secret. Copy the body bytes exactly when recomputing each digest.

Valid signature
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.
Receiver response 200
{ ok: true, replay: false }
Expired timestamp
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.
Receiver response 400
{ ok: false, reason: 'timestamp_expired' }
Receiver-side policy. This expiry check is not part of the Veridact signature protocol: there is no 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.
Mismatched digest
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.
Receiver response 400
{ ok: false, reason: 'signature_mismatch' }
Expected outcomes. A valid first delivery returns 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"), and processing_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, and review_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, and escalated_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), and completed_at (ISO 8601 string).
  • subject_check.failed: subject_check_id (number), account_id (number), status (the string "failed"), error_message (string), and failed_at (ISO 8601 string).
  • subject_check.re_reviewed: correction-based stable event_id, numeric account_id, subject_id, and subject_check_id, previous_verdict and resulting_verdict strings, reviewed_at (ISO 8601 string), and decision_context with numeric correction_id, numeric analyst_id, and the recorded reason. For monitored checks, subject_id is the monitoring subscription’s original subject; for standalone checks it falls back to the Person Check ID.
  • subject_check.verdict_overridden: correction-based stable event_id, numeric tenant account_id, numeric subject_id and subject_check_id, distinct previous_verdict and replacement_verdict strings, overridden_at (ISO 8601 string), and override_context with numeric correction_id, numeric analyst_id, and the recorded reason. For monitored checks, subject_id is the monitoring subscription’s original subject; for standalone checks it falls back to the Person Check ID.
  • batch_check.completed: numeric tenant account_id, stable batch_id, status ("completed" when every item completed, "failed" when every item failed, or "partial" for a mixture), total/completed/failed counts, input-indexed subjects, a separate failures array, partial_completion counts/details, and one persisted ISO 8601 completed_at. Each subject has its subject_check_id plus a verdict or failure detail.
  • monitoring.alert: event_id (UUID string), numeric account_id, monitoring_subscription_id (UUID string), numeric original subject_id, numeric current subject_check_id, a subject identity object, signal_type ("sanctions", "pep", or "adverse_media"), non-empty matched_source_details, detected_at (ISO 8601 string), and risk_context.
  • monitoring.verdict_changed: event_id (UUID string), numeric account_id, monitoring_subscription_id (UUID string), numeric original subject_id, numeric current subject_check_id, distinct previous_verdict and current_verdict strings, changed_at (ISO 8601 string), and decision_context with previous/current decision versions, confidence levels, and finding counts.
  • monitoring.risk_changed: event_id (UUID string), numeric account_id, monitoring_subscription_id (UUID string), numeric original subject_id, numeric current subject_check_id, a subject identity object, non-empty changed_fields with previous/current values, current_verdict (string), and changed_at (ISO 8601 string).
  • monitoring.status_changed: event_id (UUID string), numeric account_id, monitoring_subscription_id (UUID string), numeric original subject_id, numeric subject_check_id, distinct previous_status and current_status values ("active" or "paused"), and changed_at (ISO 8601 string).
  • subject_check.retry: subject_check_id (number), retried_at (ISO 8601 string), previous_delivery_id (delivery UUID string), and attempt_number (number). The same check ID is retained for routing and deduplication.
  • webhook.test: delivered_at (ISO 8601 string) and the required subject object containing id, name, first_name, and last_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.

Stable vs. event-specific fields. The 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.
Delivery headers (outside the JSON body)
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.

Processing payload
{
  "event": "subject_check.processing",
  "subject_check_id": 12345,
  "account_id": 678,
  "status": "running",
  "processing_at": "2026-08-30T12:00:01.000Z"
}
Review-required payload
{
  "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"
}
Escalated payload
{
  "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"
}
Canonical completed payload
{
  "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"
}
Failed payload
{
  "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"
}
Canonical batch completed payload
{
  "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"
}
Monitoring alert payload
{
  "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"
    }
  }
}
Monitoring verdict changed payload
{
  "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
    }
  }
}
Monitoring risk changed payload
{
  "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"
}
Monitoring status changed payload
{
  "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"
}
Verdict overridden payload
{
  "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"
  }
}
Subject check re-reviewed payload
{
  "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"
  }
}
Manual retry payload
{
  "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
}
Operator test payload
{
  "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"
  }
}
Analyst verdict overrides. 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.
Analyst re-reviews. 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 baseline. A monitoring alert is emitted only when a new Tier 6 adverse-media article or Tier 7 sanctions or PEP match appears in an account monitoring rerun and was absent from the immediately previous completed result. Adverse-media identity uses normalized headline and source identity, so score, retrieval-time, and provenance changes do not create repeat alerts. The original watched result seeds the initial baseline, so its existing signals do not alert. A 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.
Watch status changes. 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.

  1. Durably accept only events that pass signature verification.
  2. Deduplicate live deliveries by event type plus subject_check_id; for monitoring.alert, monitoring.verdict_changed, monitoring.risk_changed, and monitoring.status_changed, use the signed event_id together with monitoring_subscription_id and subject_check_id; for subject_check.verdict_overridden and subject_check.re_reviewed, deduplicate by their stable correction-based event_id; for subject_check.retry, include previous_delivery_id (or another stable attempt-specific field) when separate retry events must remain distinct.
  3. Use event-specific handling for webhook.test payloads, which do not have a live check ID.
  4. Return a 2xx only after the verified event is durably accepted or safely queued.
Recommended boundary. Verify the raw bytes at the edge, persist an idempotency record and the event body, enqueue business work, then acknowledge. This keeps retries safe without treating a single delivery attempt as proof that downstream processing completed. The short example below shows the durable acceptance boundary.
// 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.

What your handler should assume. Any accepted event may be delivered again, and an exhausted delivery does not mean your business event was not created. Persist the verified event and monitor your own processing outcome independently of the HTTP attempt history.

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.

Practical rule. Treat retries and out-of-order arrivals as normal delivery conditions: deduplicate at durable storage, process each newly accepted event once, and reconcile per-subject state when later events reveal a newer business outcome.

7. Production endpoint checklist

  • Register the endpoint. Call POST /api/v1/compliance/webhooks with the account bearer token, use an HTTPS callback, and store the returned signing_secret server-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-Signature with 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.test its 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, and 429; other 4xx responses 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.

Was this page helpful?