> ## Documentation Index
> Fetch the complete documentation index at: https://docs.veridox.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time event notifications via secure webhook delivery

## How It Works

Webhooks let you receive automatic notifications when events occur in Veridox, such as a file analysis completing.

<Steps>
  <Step title="Create a webhook configuration">
    An organisation owner creates a webhook configuration via the API, providing a destination URL where you want to receive events.
  </Step>

  <Step title="Store your signing secret">
    Veridox provisions a signing secret (format: `whsec_{32-char-alphanumeric}`) and returns it **once** in the creation response. After that, only a prefix is shown for identification.

    <Warning>The secret is only shown once at creation time. Store it securely — you cannot retrieve it again.</Warning>
  </Step>

  <Step title="Receive events">
    When events occur, Veridox sends a `POST` request to your URL with:

    * A JSON payload in the body
    * An `x-vdx-signature` header containing an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret
  </Step>

  <Step title="Verify and process">
    You verify the signature on your server to confirm the request genuinely came from Veridox and hasn't been tampered with, then process the event.
  </Step>
</Steps>

## Event Types

| Event type                | Description                          |
| ------------------------- | ------------------------------------ |
| `file.enrichment.success` | File analysis completed successfully |
| `file.enrichment.failed`  | File analysis encountered an error   |

## Payload Structure

When a file analysis completes successfully, Veridox delivers a payload like this:

```json theme={null}
{
  "event_type": "file.enrichment.success",
  "file_id": "019e2a1b-7c3d-7f00-8a1b-2c3d4e5f6a7b",
  "case_id": "019e2a1b-5b2c-7e00-9d8c-7b6a5f4e3d2c",
  "started_at": "2026-02-17T10:30:00.000Z",
  "completed_at": "2026-02-17T10:30:42.000Z",
  "version": "2.0.0",
  "file_info": {
    "md5": "d41d8cd98f00b204e9800998ecf8427e",
    "mimetype": "application/pdf"
  },
  "highlights": ["Metadata manipulation detected", "AI-generated content indicators found"],
  "results": {
    "overall_risk_score": "high",
    "summary": "Multiple indicators of document manipulation detected...",
    "findings": ["..."],
    "observations": ["..."],
    "risk_assessments": ["..."],
    "suggested_actions": ["..."],
    "audit_trail": ["..."],
    "search_evidence_summary": {
      "total_queries": 7,
      "total_results": 21,
      "per_module": {
        "content_verification": {
          "queries": ["..."],
          "results": ["..."],
          "cited_result_ids": ["q1.0", "q2.1"]
        }
      }
    }
  },
  "modules": [
    {
      "module_id": "content_verification",
      "module_name": "Content Verification",
      "findings": [
        {
          "alert_type": "ENTITY_NOT_VERIFIED",
          "alert_type_name": "Entity Not Verified",
          "found": true,
          "severity": "Medium",
          "severity_justification": "Organisation not present in public registry records.",
          "description": "ACME Ltd is named as the issuer but does not appear in the company registry.",
          "evidence": [
            {
              "source": "Search Evidence",
              "detail": "q1.0",
              "explanation": "Registry search returned no matching company"
            }
          ],
          "evidence_refs": ["q1.0"]
        }
      ],
      "search_evidence": {
        "queries": [
          {
            "query_id": "q1",
            "query_text": "ACME Ltd company registration",
            "answer": "No active registration found for ACME Ltd."
          }
        ],
        "results": [
          {
            "result_id": "q1.0",
            "query_id": "q1",
            "url": "https://example.com/registry/acme-ltd",
            "title": "Company search — ACME Ltd",
            "snippet": "No active company found...",
            "published_date": null,
            "last_updated": "2026-02-17"
          }
        ],
        "failures": [],
        "elapsed_ms": 1320,
        "request_count": 1
      },
      "verification_evidence": null
    }
  ],
  "artifacts": {
    "report_pdf": "reports/019e2a1b-7c3d-7f00.../report.pdf",
    "report_log": "reports/019e2a1b-7c3d-7f00.../report.log"
  },
  "analysis_metadata": {
    "...": "..."
  }
}
```

<Info>For `file.enrichment.failed` events, the payload includes an `error` field instead of `results`.</Info>

<Info>The `highlights` field within `results` is omitted when the analysis did not produce any highlights.</Info>

## Module Evidence

Each entry in `modules` carries its findings alongside the evidence that supports them:

| Field                   | Type           | Description                                                                                                                                                                                      |
| ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `findings`              | array          | Structured findings. Each has an `alert_type`, a `severity` (`Informational`, `Low`, `Medium`, or `High`), a `description`, a `severity_justification`, an `evidence` list, and `evidence_refs`. |
| `search_evidence`       | object \| null | Audit trail of the search and grounding queries run for this module — the `queries`, their `results`, any `failures`, and timing. `null` when the module performed no search.                    |
| `verification_evidence` | object \| null | Reserved for registry verification check results. Currently always `null`.                                                                                                                       |

A finding's `evidence_refs` are IDs that point back into the module's evidence so you can resolve each claim to its source:

* IDs like `q1.0` resolve against `search_evidence.results` (matched on `result_id`).
* IDs like `v1` resolve against `verification_evidence.checks` (reserved for future use).

The `results.search_evidence_summary` object is a cross-module roll-up of the same queries and results, keyed by `module_id` — useful for rendering an overview without walking every module.

<Info>`search_evidence_summary` and a query's `answer` field may be `null` (or, for analyses produced before these fields existed, absent). Treat both as optional when parsing.</Info>

## Signature Verification

The signature is computed as:

```
HMAC-SHA256(raw_request_body, your_webhook_secret) → hex string
```

You compare the `x-vdx-signature` header value against your own computed HMAC. Always use a **timing-safe comparison** to prevent timing attacks.

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    import { createHmac, timingSafeEqual } from "node:crypto";
    import { createServer } from "node:http";

    const WEBHOOK_SECRET = "whsec_your_secret_here";

    function verifySignature(body, signature) {
      if (!signature) return false;
      const expected = createHmac("sha256", WEBHOOK_SECRET)
        .update(body)
        .digest("hex");
      const sigBuf = Buffer.from(signature, "utf8");
      const expBuf = Buffer.from(expected, "utf8");
      if (sigBuf.length !== expBuf.length) return false;
      return timingSafeEqual(sigBuf, expBuf);
    }

    createServer(async (req, res) => {
      if (req.method !== "POST") {
        res.writeHead(404).end();
        return;
      }

      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      const body = Buffer.concat(chunks);

      if (!verifySignature(body, req.headers["x-vdx-signature"])) {
        res.writeHead(401, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "Invalid signature" }));
        return;
      }

      const event = JSON.parse(body.toString("utf8"));
      console.log("Verified webhook event:", event);

      // Process the event...

      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    }).listen(3000, () => console.log("Webhook receiver on :3000"));
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hmac
    import hashlib
    import json
    from http.server import HTTPServer, BaseHTTPRequestHandler

    WEBHOOK_SECRET = "whsec_your_secret_here"

    class WebhookHandler(BaseHTTPRequestHandler):
        def do_POST(self):
            content_length = int(self.headers.get("Content-Length", 0))
            body = self.rfile.read(content_length)
            signature = self.headers.get("x-vdx-signature", "")

            expected = hmac.new(
                WEBHOOK_SECRET.encode(), body, hashlib.sha256
            ).hexdigest()

            if not hmac.compare_digest(signature, expected):
                self.send_response(401)
                self.end_headers()
                self.wfile.write(b'{"error": "Invalid signature"}')
                return

            event = json.loads(body)
            print("Verified webhook event:", event)

            # Process the event...

            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'{"ok": true}')

    HTTPServer(("", 3000), WebhookHandler).serve_forever()
    ```
  </Tab>
</Tabs>

## Best Practices

<Check>**Store secrets securely** — the signing secret is shown once at creation time and cannot be retrieved again</Check>
<Check>**Always verify signatures** — validate every incoming request before processing the payload</Check>
<Check>**Use timing-safe comparison** — use `timingSafeEqual` (Node.js) or `hmac.compare_digest` (Python) to prevent timing attacks</Check>
<Check>**Return 2xx quickly** — acknowledge receipt immediately and do heavy processing asynchronously</Check>

<Info>Only one active webhook configuration is allowed per organisation at a time. Creating a new configuration deactivates the previous one.</Info>
