> ## Documentation Index
> Fetch the complete documentation index at: https://cortex-e852fafe-t3code-rewrite-docs-declutter.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get an HTTP callback when a document or memory finishes indexing, instead of polling for status.

A webhook is an HTTP `POST` that HydraDB sends to your endpoint when an ingested document, memory, or app item reaches a terminal indexing state: `completed` or `errored`. Use it to mark content as ready in your own database, tell a user their upload finished, kick off a downstream job, or catch indexing failures without polling. You register one endpoint per workspace with [`POST /webhooks/indexing`](/api-reference/v2/endpoint/register-webhook).

Webhooks fire only for terminal states. For progress before completion, poll [`GET /context/status`](/api-reference/v2/endpoint/source-status).

This guide builds up in order:

1. [Register a webhook](#1-register-a-webhook): one request, with signing enabled.
2. [Read the payload you receive](#2-read-the-payload-you-receive): headers and fields.
3. [Verify the signature](#3-verify-the-signature): with the SDK helper, or in a few lines of your own, plus full receiver examples.
4. [Track deliveries and retries](#4-track-deliveries-and-retries): states, idempotency, and the delivery history.
5. [Manage the signing secret](#5-manage-the-signing-secret): generate, supply, disable, and rotate without a gap.
6. [Security checklist](#security-checklist), [common issues](#common-issues), and [related pages](#related).

<a id="how-it-works" />

<a id="2-register-a-webhook" />

<a id="register-with-curl" />

## 1. Register a webhook

The request below registers your endpoint for the one supported event, `indexing.status_changed`, and asks HydraDB to generate a signing secret in the same call. The secret comes back once, in this response, and cannot be read again, so copy it into your receiver's secret store right away.

<Accordion title="Register a webhook with signing: Python, TypeScript, or cURL">
  <CodeGroup>
    ```python Python SDK theme={"dark"}
    import os
    from hydra_db import HydraDB

    client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"])

    result = client.webhooks.register(
        url="https://api.example.com/webhooks/hydradb",
        event_types=["indexing.status_changed"],
        generate_signing_secret=True,
    )
    # The signing secret is in result.data. It is shown once.
    ```

    ```typescript TypeScript SDK theme={"dark"}
    import { HydraDBClient } from "@hydradb/sdk";

    const client = new HydraDBClient({
      token: process.env.HYDRA_DB_API_KEY,
    });

    const result = await client.webhooks.register({
      url: "https://api.example.com/webhooks/hydradb",
      eventTypes: ["indexing.status_changed"],
      generateSigningSecret: true,
    });
    // The signing secret is in result.data. It is shown once.
    ```

    ```bash cURL theme={"dark"}
    curl -X POST 'https://api.hydradb.com/webhooks/indexing' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://api.example.com/webhooks/hydradb",
        "event_types": ["indexing.status_changed"],
        "generate_signing_secret": true
      }'
    ```

    ```json Response data theme={"dark"}
    {
      "registered": true,
      "url": "https://api.example.com/webhooks/hydradb",
      "event_types": ["indexing.status_changed"],
      "signing_secret_configured": true,
      "signing_secret": "whsec_Yk8sQ2pWc0xuNGRIeUZ0UmJKZ1B3WHZNaTdaYUUxcW8",
      "message": "Webhook registered and signing enabled. Copy the signing secret now - it cannot be retrieved later."
    }
    ```
  </CodeGroup>
</Accordion>

### What each property means

| Property | What it is |
| - | - |
| `url` | Your endpoint. It must be reachable over public HTTPS; localhost and private network addresses are rejected. |
| `event_types` | The events to subscribe to. `indexing.status_changed` is the only one today. It fires when an item reaches `completed`, `errored`, or `success`; `success` is a legacy alias for `completed`. |
| `generate_signing_secret` | Ask HydraDB to create a strong secret and return it once. Send this or `signing_secret`, not both; sending both returns `422`. |
| `signing_secret` | Supply your own secret instead, at least 16 characters. Useful when you want to deploy it to your receiver before HydraDB starts using it. |

Signing is optional but strongly recommended: without it, your endpoint cannot tell a real HydraDB delivery from anything else that can reach the URL. [Section 3](#3-verify-the-signature) shows how to verify it.

One webhook is registered per workspace, so calling `POST /webhooks/indexing` again replaces the registration. Omitting `signing_secret` when you do **preserves** the secret you already have; editing the URL or the event list never changes your signing configuration. To turn signing off, call `DELETE /webhooks/indexing/signing-secret` explicitly, as in [section 5](#5-manage-the-signing-secret).

You can also register from the **Webhooks** page of the dashboard: enter the URL, enable signing, and copy the secret when it is shown. Either way, send a test delivery next to confirm the endpoint is reachable and your verifier works.

<Accordion title="Check, replace, test, or delete the registration: cURL">
  <CodeGroup>
    ```bash Check the current registration theme={"dark"}
    curl 'https://api.hydradb.com/webhooks/indexing' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```json Registration data theme={"dark"}
    {
      "registered": true,
      "url": "https://api.example.com/webhooks/hydradb",
      "event_types": ["indexing.status_changed"],
      "signing_secret_configured": true
    }
    ```

    ```bash Replace the URL, keeping the secret theme={"dark"}
    curl -X POST 'https://api.hydradb.com/webhooks/indexing' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://api.example.com/webhooks/hydradb-v2",
        "event_types": ["indexing.status_changed"]
      }'
    ```

    ```bash Send a test delivery theme={"dark"}
    curl -X POST 'https://api.hydradb.com/webhooks/indexing/test' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```json Test delivery data theme={"dark"}
    {
      "delivered": true,
      "status_code": 204,
      "message": "Test delivery succeeded."
    }
    ```

    ```bash Delete the webhook theme={"dark"}
    curl -X DELETE 'https://api.hydradb.com/webhooks/indexing' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```
  </CodeGroup>

  `signing_secret_configured` only says whether a secret is set; the secret itself is never returned. Deleting the webhook discards the stored secret along with the registration.
</Accordion>

The management endpoints return the standard v2 `{ success, data, error, meta }` envelope used by `/databases`, `/context/*`, and `/query`; the examples on this page show only `data`. The event sent to your webhook URL is not wrapped.

<a id="3-request-format" />

<a id="headers" />

<a id="payload" />

<a id="4-test-delivery-payload" />

## 2. Read the payload you receive

Each delivery is a `POST` with a JSON body and these headers:

| Header | Description |
| - | - |
| `Content-Type` | Always `application/json` |
| `X-HydraDB-Delivery-ID` | Stable delivery ID for this event |
| `X-HydraDB-Event` | Event name, such as `indexing.status_changed` |
| `X-HydraDB-Signature` | `sha256=<hex>`, the HMAC-SHA256 of the raw request body keyed by your signing secret. Present only when signing is configured. See [Verify the signature](#3-verify-the-signature) |

<Accordion title="Delivery payloads for completed, errored, and test events: JSON">
  <CodeGroup>
    ```json Completed theme={"dark"}
    {
      "event": "indexing.status_changed",
      "delivery_id": "<delivery_id>",
      "id": "<id>",
      "tenant_id": "<database_id>",
      "database": "<database>",
      "sub_tenant_id": "<collection>",
      "collection": "<collection>",
      "status": "completed",
      "timestamp": "<ISO-8601 timestamp>"
    }
    ```

    ```json Errored theme={"dark"}
    {
      "event": "indexing.status_changed",
      "delivery_id": "<delivery_id>",
      "id": "<id>",
      "tenant_id": "<database_id>",
      "database": "<database>",
      "sub_tenant_id": "<collection>",
      "collection": "<collection>",
      "status": "errored",
      "timestamp": "<ISO-8601 timestamp>",
      "error_code": "<error_code>",
      "error_message": "<error_message>"
    }
    ```

    ```json Test delivery theme={"dark"}
    {
      "event": "indexing.status_changed",
      "delivery_id": "test_<random>",
      "id": "test_document",
      "tenant_id": "<org_id>",
      "database": "<org_id>",
      "sub_tenant_id": "<org_id>",
      "collection": "<org_id>",
      "status": "completed",
      "timestamp": "<ISO-8601 timestamp>",
      "test": true
    }
    ```
  </CodeGroup>

  A test delivery is synthetic: nothing was ingested, so there is no database name to report, and every scope field including `database` is set to your organisation ID. Match on `test: true` (or the `test_` prefix on `delivery_id`) to tell it apart from a real delivery.
</Accordion>

| Field | Description |
| - | - |
| `event` | Event type. Currently `indexing.status_changed`. |
| `delivery_id` | Stable ID for this event. Store it to deduplicate retries. |
| `id` | The document, memory, or app item ID you supplied during ingestion. |
| `database` | The name of the database you ingested into: the value you sent as `database` (or `tenant_id`) on the ingest request. Empty only for items ingested before this field existed; read `tenant_id` if you need a scope that is always set. |
| `collection` | Collection scope for the indexed item. |
| `status` | Terminal indexing status. Usually `completed` or `errored`. |
| `timestamp` | Time the webhook payload was created. |
| `error_code` | Present when available for failed processing. |
| `error_message` | Present when available for failed processing. |
| `tenant_id` | Deprecated. An identifier for the database, not the name you ingested into. Always present. |
| `sub_tenant_id` | Deprecated alias for `collection`, carrying the same value. |

`tenant_id` and `database` do not carry the same value. `database` is the name you ingested into, such as `marketing-docs`; `tenant_id` is an identifier for it, such as `kv3qz7mabx`. Route and filter on `database`: it is the only field that matches what you sent. `tenant_id` still carries the same identifier it always has, so integrations matching on it keep working, and `sub_tenant_id` remains an exact alias for `collection`. Read `database` and `collection` in new integrations.

Older examples may call the item identifier `doc_id`. New payloads use `id`; the delivery history in [section 4](#4-track-deliveries-and-retries) still uses `doc_id`.

<a id="5-verifying-signatures" />

<a id="6-receiver-examples" />

## 3. Verify the signature

When signing is configured, every delivery carries `X-HydraDB-Signature: sha256=<lowercase hex digest>`, where the digest is HMAC-SHA256 with your signing secret as the key over the raw request body bytes. Three details decide whether your verifier works:

* The `sha256=` prefix is **part of the header value**, not a separate field. Compare against the whole string.
* The digest is **lowercase hex**, not base64.
* The HMAC is computed over the **raw body bytes exactly as received**. Parsing the JSON and re-serialising it produces different bytes and the signature will not match.

If you use an official SDK, the verifier is already there:

<Accordion title="Verify with the SDK helper: Python or TypeScript">
  <CodeGroup>
    ```python Python SDK theme={"dark"}
    from hydra_db.helpers import verify_webhook_signature

    if not verify_webhook_signature(secret, raw_body, signature):
        ...  # reject
    ```

    ```typescript TypeScript SDK theme={"dark"}
    import { verifyWebhookSignature } from "@hydradb/sdk/helpers";

    if (!verifyWebhookSignature(secret, rawBody, signature)) {
      // reject
    }
    ```
  </CodeGroup>
</Accordion>

Otherwise, implement it directly. Both versions use a constant-time comparison, which matters: a naive `==` leaks timing information that can be used to forge a signature byte by byte.

<Accordion title="Verify without the SDK: Python or TypeScript">
  <CodeGroup>
    ```python Python theme={"dark"}
    import hashlib
    import hmac


    def verify_webhook_signature(secret: str, raw_body: bytes, signature: str) -> bool:
        """Return True when signature is valid for raw_body."""
        if not secret or not signature:
            return False
        digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(signature, f"sha256={digest}")
    ```

    ```typescript TypeScript theme={"dark"}
    import crypto from "node:crypto";

    export function verifyWebhookSignature(
      secret: string,
      rawBody: Buffer,
      signature: string | undefined,
    ): boolean {
      if (!secret || !signature) {
        return false;
      }
      const expected =
        "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

      const expectedBuffer = Buffer.from(expected);
      const signatureBuffer = Buffer.from(signature);

      // timingSafeEqual throws on a length mismatch, so guard before comparing.
      if (signatureBuffer.length !== expectedBuffer.length) {
        return false;
      }
      return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
    }
    ```
  </CodeGroup>
</Accordion>

<Warning>
  If the signing secret is missing from your environment, reject the request rather than skipping verification.
</Warning>

Your endpoint should return a `2xx` quickly and do any slow work after it acknowledges the request. The receivers below wire the verifier into a real handler; both read the **raw** body before parsing.

<Accordion title="Receiver examples: Python (FastAPI) or TypeScript (Express)">
  <CodeGroup>
    ```python Python expandable theme={"dark"}
    import hashlib
    import hmac
    import json
    import os

    from fastapi import FastAPI, Header, Request, Response

    app = FastAPI()

    # Fail fast at startup when the secret is missing, rather than accepting
    # unverified deliveries at request time.
    SIGNING_SECRET = os.environ["HYDRADB_WEBHOOK_SECRET"]


    def verify_webhook_signature(secret: str, raw_body: bytes, signature: str | None) -> bool:
        if not secret or not signature:
            return False
        digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(signature, f"sha256={digest}")


    @app.post("/webhooks/hydradb")
    async def hydradb_webhook(
        request: Request,
        delivery_id: str | None = Header(default=None, alias="X-HydraDB-Delivery-ID"),
        signature: str | None = Header(default=None, alias="X-HydraDB-Signature"),
    ):
        raw_body = await request.body()

        if not verify_webhook_signature(SIGNING_SECRET, raw_body, signature):
            return Response(status_code=401)

        event = json.loads(raw_body)

        # Store delivery_id and skip it if you have already processed it.
        print("HydraDB webhook", delivery_id, event["id"], event["status"])

        return Response(status_code=204)
    ```

    ```typescript TypeScript expandable theme={"dark"}
    import crypto from "node:crypto";
    import express from "express";

    const app = express();

    // Fail fast at startup when the secret is missing, rather than accepting
    // unverified deliveries at request time.
    const signingSecret = process.env.HYDRADB_WEBHOOK_SECRET;
    if (!signingSecret) {
      throw new Error("HYDRADB_WEBHOOK_SECRET is required");
    }

    function verifyWebhookSignature(
      secret: string,
      rawBody: Buffer,
      signature: string | undefined,
    ): boolean {
      if (!secret || !signature) {
        return false;
      }
      const expected =
        "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

      const expectedBuffer = Buffer.from(expected);
      const signatureBuffer = Buffer.from(signature);

      if (signatureBuffer.length !== expectedBuffer.length) {
        return false;
      }
      return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
    }

    app.post(
      "/webhooks/hydradb",
      // express.raw is essential: the signature covers the raw bytes, so the body
      // must not be parsed before verification.
      express.raw({ type: "application/json" }),
      (req, res) => {
        const deliveryId = req.header("X-HydraDB-Delivery-ID");
        const signature = req.header("X-HydraDB-Signature");
        const rawBody = req.body as Buffer;

        if (!verifyWebhookSignature(signingSecret, rawBody, signature)) {
          return res.status(401).send("Invalid signature");
        }

        const event = JSON.parse(rawBody.toString("utf8"));

        // Store deliveryId and skip it if you have already processed it.
        console.log("HydraDB webhook", deliveryId, event.id, event.status);

        return res.status(204).send();
      }
    );

    app.listen(3000);
    ```
  </CodeGroup>
</Accordion>

<a id="7-delivery-and-retries" />

## 4. Track deliveries and retries

When an item reaches a terminal state, HydraDB creates a delivery record, sends the `POST`, and marks the record delivered or schedules a retry. Every attempt is recorded, and you can inspect the history from the dashboard Webhooks page or over the API.

| State | Meaning |
| - | - |
| `pending` | The event was recorded and is waiting to be sent. |
| `sweeping` | HydraDB has claimed the event for delivery or retry. |
| `delivered` | Your endpoint returned a successful status code. |
| `failed` | The current send attempt failed and will be retried. |
| `permanently_failed` | HydraDB stopped retrying this event. On HydraDB Cloud this happens after 16 attempts. |

HydraDB retries failed deliveries in the background. If a worker shuts down during delivery, the sweep process recovers the event later. Test deliveries do not appear in the history.

Because of retries, your receiver should be idempotent:

* Store `delivery_id`.
* If the same `delivery_id` arrives again, return `2xx` without repeating side effects.
* Do not depend on receiving events exactly once.

<Accordion title="List, filter, page, fetch, and retry deliveries: cURL">
  <CodeGroup>
    ```bash List deliveries theme={"dark"}
    curl 'https://api.hydradb.com/webhooks/indexing/deliveries?limit=20' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```json List data theme={"dark"}
    {
      "deliveries": [
        {
          "delivery_id": "<delivery_id>",
          "doc_id": "<id>",
          "status": "delivered",
          "indexing_status": "completed",
          "event_type": "indexing.status_changed",
          "attempts": 1,
          "error_code": null,
          "error_message": null,
          "created_at": "<ISO-8601 timestamp>",
          "updated_at": "<ISO-8601 timestamp>"
        }
      ],
      "count": 1,
      "next_cursor": null
    }
    ```

    ```bash Only failed deliveries theme={"dark"}
    curl 'https://api.hydradb.com/webhooks/indexing/deliveries?limit=20&status=failed' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```bash Next page theme={"dark"}
    curl 'https://api.hydradb.com/webhooks/indexing/deliveries?limit=20&cursor=<next_cursor>' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```bash One delivery theme={"dark"}
    curl 'https://api.hydradb.com/webhooks/indexing/deliveries/<delivery_id>' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```bash Retry a failed delivery theme={"dark"}
    curl -X POST 'https://api.hydradb.com/webhooks/indexing/deliveries/<delivery_id>/retry' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```json Retry data theme={"dark"}
    {
      "delivery_id": "<delivery_id>",
      "queued": true,
      "message": "Delivery queued for retry."
    }
    ```
  </CodeGroup>

  The history uses `doc_id` for the item; the outbound payload uses `id`. `status` accepts any of the delivery states above. `limit` is `1` to `100` (default `20`); when `next_cursor` is not `null`, pass it back as `cursor`. `<delivery_id>` is the value from a payload or from the list.
</Accordion>

Only `failed` and `permanently_failed` deliveries can be retried by hand; for any other state the call returns `queued: false`. A retry is signed with your **current** signing secret, not the one in force when the delivery was first attempted, so after a rotation your receiver must know the new secret.

<a id="manage-the-signing-secret" />

<a id="edit-a-webhook" />

<a id="8-advanced-patterns" />

## 5. Manage the signing secret

`POST /webhooks/indexing/signing-secret` enables or rotates signing after registration, and `DELETE` on the same path disables it. There are two ways to set a secret:

* **Let HydraDB generate one:** send no body. The secret is returned exactly once, in plain text, and takes effect immediately.
* **Supply your own:** send `signing_secret`, at least 16 characters. Use this when you want to deploy the secret to your receiver first and hand it to HydraDB afterwards, so there is no window where deliveries are signed with a secret your endpoint does not yet know.

<Accordion title="Generate, supply, or disable the signing secret: cURL">
  <CodeGroup>
    ```bash Generate a secret theme={"dark"}
    curl -X POST 'https://api.hydradb.com/webhooks/indexing/signing-secret' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```

    ```json Generate data theme={"dark"}
    {
      "signing_secret": "whsec_Yk8sQ2pWc0xuNGRIeUZ0UmJKZ1B3WHZNaTdaYUUxcW8",
      "generated": true,
      "message": "Signing secret generated. Copy it now - it cannot be retrieved later, and it takes effect immediately."
    }
    ```

    ```bash Supply your own theme={"dark"}
    curl -X POST 'https://api.hydradb.com/webhooks/indexing/signing-secret' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2" \
      -H "Content-Type: application/json" \
      -d '{ "signing_secret": "at-least-16-characters" }'
    ```

    ```bash Disable signing theme={"dark"}
    curl -X DELETE 'https://api.hydradb.com/webhooks/indexing/signing-secret' \
      -H "Authorization: Bearer $HYDRA_DB_API_KEY" \
      -H "API-Version: 2"
    ```
  </CodeGroup>

  Disabling removes the stored secret, and deliveries stop carrying `X-HydraDB-Signature`.
</Accordion>

<Warning>
  Rotating takes effect immediately, with no overlap period. Deliveries in flight during a rotation are signed with the new secret, so a receiver that only knows the old one will reject them. Follow the steps below to rotate without a gap.
</Warning>

<a id="zero-downtime-key-rotation" />

### Rotate without downtime

HydraDB signs each delivery with one secret, so the overlap has to live in your receiver: deploy it with both secrets before rotating in HydraDB, and supply the new secret yourself so you know it before it takes effect.

<Steps>
  <Step title="Choose the new secret yourself">
    Generate a high-entropy value with your own tooling, for example `openssl rand -base64 32`. Do not use the generate option here: a generated secret is only revealed after it is already in effect, which is exactly the window you are trying to avoid.
  </Step>

  <Step title="Teach your receiver both secrets">
    Deploy your receiver so it accepts either the current secret or the new one, reading both from your environment. At this point nothing has changed on the HydraDB side, so every delivery still verifies against the old secret.
  </Step>

  <Step title="Rotate in HydraDB, supplying that secret">
    In the dashboard, open **Rotate**, tick **I'll use my own secret**, and paste the value. Over the API, `POST /webhooks/indexing/signing-secret` with a `signing_secret` body. Deliveries switch to the new secret immediately, and your receiver already accepts it.
  </Step>

  <Step title="Retire the old secret">
    Once you have confirmed deliveries are verifying against the new secret, remove the old one from your receiver and redeploy. You are back to a single active secret.
  </Step>
</Steps>

<Accordion title="A receiver that accepts either secret: Python or TypeScript">
  <CodeGroup>
    ```python Python theme={"dark"}
    import hashlib
    import hmac
    import os

    # During a rotation both are set. Afterwards, drop HYDRADB_WEBHOOK_SECRET_OLD.
    SECRETS = [
        s for s in (
            os.environ["HYDRADB_WEBHOOK_SECRET"],
            os.environ.get("HYDRADB_WEBHOOK_SECRET_OLD"),
        ) if s
    ]


    def verify_webhook_signature(raw_body: bytes, signature: str) -> bool:
        if not signature:
            return False
        for secret in SECRETS:
            digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
            if hmac.compare_digest(signature, f"sha256={digest}"):
                return True
        return False
    ```

    ```typescript TypeScript theme={"dark"}
    import crypto from "node:crypto";

    // During a rotation both are set. Afterwards, drop HYDRADB_WEBHOOK_SECRET_OLD.
    const secrets = [
      process.env.HYDRADB_WEBHOOK_SECRET,
      process.env.HYDRADB_WEBHOOK_SECRET_OLD,
    ].filter((s): s is string => Boolean(s));

    export function verifyWebhookSignature(rawBody: Buffer, signature: string | undefined): boolean {
      if (!signature) {
        return false;
      }
      return secrets.some((secret) => {
        const expected =
          "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
        const expectedBuffer = Buffer.from(expected);
        const signatureBuffer = Buffer.from(signature);
        if (signatureBuffer.length !== expectedBuffer.length) {
          return false;
        }
        return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
      });
    }
    ```
  </CodeGroup>
</Accordion>

Keep the window short and remove the old secret once the rotation is confirmed.

<a id="9-security-checklist" />

## Security checklist

* Use HTTPS for your webhook URL.
* Enable signing with a cryptographically random secret, generated by HydraDB or your own tooling.
* Verify `X-HydraDB-Signature` using the raw request body, with a constant-time comparison.
* Fail closed. Reject the request when the secret is missing from your environment, rather than skipping verification.
* Store the secret as a secret. It belongs in your secret manager, not in source control.
* Return `2xx` only after you accept the event.
* Deduplicate using `delivery_id`.
* Keep the endpoint fast. Put slow work in a queue or background job.

<a id="10-common-issues" />

## Common issues

| Issue | What to check |
| - | - |
| Test delivery fails | Confirm your endpoint is public and returns a `2xx` status. |
| Signature check fails | Verify the HMAC is computed over the raw request body, not parsed JSON. Check you are comparing against the whole header value including the `sha256=` prefix, and that the digest is lowercase hex rather than base64. |
| Signature header is missing | Signing is not configured. Call `POST /webhooks/indexing/signing-secret` to enable it. |
| Signatures started failing after a rotation | Rotation applies immediately. Confirm your receiver has the new secret deployed, and see [Rotate without downtime](#rotate-without-downtime) to avoid the gap next time. |
| Event arrives more than once | This is expected during retries. Deduplicate with `delivery_id`. |
| Event never arrives | Check the delivery history for `failed` or `permanently_failed`. Test deliveries are not listed there. |
| `id` is unexpected | It is the ID you supplied at ingestion, such as document ID, memory ID, or app item ID. |

## Related

* [Register Webhook](/api-reference/v2/endpoint/register-webhook): the full request and response reference, with the other webhook endpoints alongside it
* [Ingestion Status](/api-reference/v2/endpoint/source-status): poll for progress before an item reaches a terminal state
* [Knowledge](/essentials/v2/knowledge): ingesting the documents whose indexing these events report on
* [SDKs](/api-reference/v2/sdks): client setup for the Python and TypeScript examples


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.