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.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.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, 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.

Stable vs. event-specific fields. The live lifecycle envelope keeps 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.
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 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"
}
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"
  }
}
Optional metadata. The current signed producers emit no guaranteed optional metadata fields. Do not require unlisted keys, and ignore additive keys introduced in the future. 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.

  1. Durably accept only events that pass signature verification.
  2. Deduplicate live deliveries by event type plus subject_check_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?