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

# Webhooks

> Register an HTTPS endpoint and Anona posts to it when something happens in a space, so you never have to poll.

Every delivery is signed with HMAC-SHA256 and retried if your endpoint is down.

## Events

| Event                       | Fires when                                                     |
| --------------------------- | -------------------------------------------------------------- |
| `memory.created`            | An item finished being stored and indexed.                     |
| `memory.consolidated`       | Related memories were merged into a consolidated note.         |
| `security.policy_triggered` | Content matched a security policy and was redacted or blocked. |

## From the dashboard

Everything below is also available without writing code. Open a space in the dashboard
and pick the **Webhooks** tab, where you can add and edit endpoints, choose which events
they receive, pause one without deleting it, and read the delivery log for a receiver
that is not responding.

<Warning>
  The signing secret is shown once, immediately after you add the webhook. It is never
  displayed again, in the dashboard or in the API.
</Warning>

## Register a webhook

```http theme={null}
POST /v1/spaces/{space_id}/webhooks
Authorization: Bearer anona_live_YOUR_KEY
Content-Type: application/json

{
  "url": "https://example.com/hooks/anona",
  "event_types": ["memory.created", "memory.consolidated"],
  "enabled": true
}
```

| Field         | Type      | Default              | Description                                 |
| ------------- | --------- | -------------------- | ------------------------------------------- |
| `url`         | string    | required             | HTTPS endpoint. Must be publicly reachable. |
| `event_types` | string\[] | `["memory.created"]` | Events to deliver.                          |
| `enabled`     | boolean   | `true`               | Deliveries pause while this is `false`.     |

**Response** `201 Created`

```json theme={null}
{
  "id": "wh_a1b2c3d4",
  "space_id": "customer-support-bot",
  "url": "https://example.com/hooks/anona",
  "event_types": ["memory.created", "memory.consolidated"],
  "enabled": true,
  "secret": "whsec_KJ8s...",
  "created_at": "2026-07-30T12:00:00Z",
  "updated_at": "2026-07-30T12:00:00Z"
}
```

<Warning>
  `secret` is returned only here, only once. Store it now: you need it to verify
  signatures, and it is `null` on every other response.
</Warning>

| Constraint         | Value                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| Webhooks per space | 5                                                                                              |
| Scheme             | HTTPS only. Plain `http://` is rejected.                                                       |
| Address            | Must resolve publicly. `localhost` and private ranges are rejected with `invalid_webhook_url`. |

## Delivery format

```http theme={null}
POST /hooks/anona HTTP/1.1
Content-Type: application/json
X-Anona-Event: memory.created
X-Anona-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015...

{
  "event": "memory.created",
  "space_id": "customer-support-bot",
  "operation_id": "op_7f3e2a91",
  "status": "completed",
  "timestamp": "2026-07-30T12:00:03Z",
  "data": {
    "document_id": "doc_5b1c",
    "tags": ["support"]
  }
}
```

The `data` object varies by event:

| Event                       | `data` fields                                                                           |
| --------------------------- | --------------------------------------------------------------------------------------- |
| `memory.created`            | `document_id`, `tags`                                                                   |
| `memory.consolidated`       | `observations_created`, `observations_updated`, `observations_deleted`, `error_message` |
| `security.policy_triggered` | `action` (`redact` or `block`), `detector`, `document_id`, `matched_types`, `message`   |

Fields with no value are omitted rather than sent as `null`.

Respond with any `2xx` status to acknowledge. Anything else counts as a failure and is
retried.

## Verifying signatures

`X-Anona-Signature` is `sha256=` followed by the HMAC-SHA256 of the raw request body,
keyed with your webhook secret. Compare it in constant time, never with `==` on the raw
strings.

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib

  def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
      expected = "sha256=" + hmac.new(
          secret.encode(), raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature_header)


  # FastAPI example. Note the RAW body, not the parsed JSON.
  @app.post("/hooks/anona")
  async def receive(request: Request):
      raw = await request.body()
      if not verify(raw, request.headers.get("X-Anona-Signature", ""), WEBHOOK_SECRET):
          raise HTTPException(status_code=401, detail="bad signature")
      event = json.loads(raw)
      ...
      return {"ok": True}
  ```

  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  function verify(rawBody, signatureHeader, secret) {
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(signatureHeader || "");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // Express: express.raw() keeps the body bytes intact for signing
  app.post("/hooks/anona", express.raw({ type: "application/json" }), (req, res) => {
    if (!verify(req.body, req.get("X-Anona-Signature"), WEBHOOK_SECRET)) {
      return res.status(401).send("bad signature");
    }
    const event = JSON.parse(req.body);
    res.json({ ok: true });
  });
  ```
</CodeGroup>

<Note>
  Sign the raw bytes. Parsing the JSON and re-serializing it changes key order and
  whitespace, which changes the hash. This is the most common reason verification fails.
</Note>

## Retries

A delivery is attempted up to 6 times over roughly 7 hours, then marked failed.

| Attempt | Sent after  |
| ------- | ----------- |
| 1       | Immediately |
| 2       | 5 seconds   |
| 3       | 5 minutes   |
| 4       | 30 minutes  |
| 5       | 2 hours     |
| 6       | 5 hours     |

Deliveries are at-least-once: a receiver that times out after doing its work still gets
retried. Make your handler idempotent on `operation_id`.

## Timing and ordering

| Behavior                                                               | What it means for you                                                                                                                                                                                                                                                                                              |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Webhooks never slow down your writes.**                              | The delivery is queued as part of the write and sent by a background worker, so a slow, failing, or retrying endpoint has no effect on `POST /v1/record` latency.                                                                                                                                                  |
| **Acknowledge fast, work afterwards.**                                 | Deliveries time out after 10 seconds. Return a `2xx` as soon as you have accepted the event, then do the real work asynchronously. A receiver that holds the connection open is treated as failed and retried, so slow handlers turn into duplicate deliveries.                                                    |
| **A delivery means the write is committed.**                           | The delivery is queued in the same database transaction as the memory itself. You never get an event for a write that was rolled back, and a write is never committed while silently dropping its notification.                                                                                                    |
| **The event arrives after the API response.**                          | Typically within a second or two of the write completing.                                                                                                                                                                                                                                                          |
| **A new webhook can take up to 15 seconds to start receiving events.** | Registration is immediate, but a brief internal cache means the first event or two after adding a webhook may not be delivered. If you are testing an integration and the first event does not arrive, wait a moment and write again before digging further. Edits to an existing webhook take effect immediately. |

<Tip>
  Pair webhooks with `"async": true` on `POST /v1/record`. Async ingestion returns a
  `job_id` straight away instead of holding the request open while memories are extracted
  and indexed, and the webhook is then how you learn it finished. No polling at all.
</Tip>

## List webhooks

```http theme={null}
GET /v1/spaces/{space_id}/webhooks
```

```json theme={null}
{
  "items": [
    {
      "id": "wh_a1b2c3d4",
      "space_id": "customer-support-bot",
      "url": "https://example.com/hooks/anona",
      "event_types": ["memory.created"],
      "enabled": true,
      "secret": null,
      "created_at": "2026-07-30T12:00:00Z",
      "updated_at": "2026-07-30T12:00:00Z"
    }
  ]
}
```

## Update a webhook

Only the fields you send are changed. Returns the updated webhook.

```http theme={null}
PATCH /v1/spaces/{space_id}/webhooks/{webhook_id}

{ "enabled": false }
```

## Delete a webhook

```http theme={null}
DELETE /v1/spaces/{space_id}/webhooks/{webhook_id}
```

Returns `204 No Content`. Queued deliveries for that webhook stop.

## Debugging deliveries

When a receiver is not working, inspect the attempts:

```http theme={null}
GET /v1/spaces/{space_id}/webhooks/{webhook_id}/deliveries?limit=50
```

```json theme={null}
{
  "items": [
    {
      "id": "dlv_1f2e",
      "event_type": "memory.created",
      "url": "https://example.com/hooks/anona",
      "status": "failed",
      "attempts": 3,
      "response_status": 500,
      "error": "Server error '500 Internal Server Error'",
      "next_retry_at": "2026-07-30T12:35:00Z",
      "last_attempt_at": "2026-07-30T12:05:00Z",
      "created_at": "2026-07-30T12:00:03Z"
    }
  ],
  "next_cursor": null
}
```

| Field             | Description                                                   |
| ----------------- | ------------------------------------------------------------- |
| `status`          | `pending`, `processing`, `completed`, or `failed`.            |
| `attempts`        | How many times delivery has been tried.                       |
| `response_status` | The HTTP status your endpoint last returned.                  |
| `error`           | Why the last attempt failed.                                  |
| `next_retry_at`   | When the next attempt is scheduled, or `null` if none remain. |

Pass `next_cursor` back as `cursor` to page through older deliveries.

## Errors

| Code                    | Meaning                                                                 |
| ----------------------- | ----------------------------------------------------------------------- |
| `invalid_webhook_url`   | Not HTTPS, embeds credentials, or does not resolve to a public address. |
| `unknown_event_type`    | The event name is not in the table above.                               |
| `webhook_limit_reached` | The space already has 5 webhooks.                                       |
| `no_fields`             | The `PATCH` body was empty.                                             |

See [Errors](/api-reference/errors) for the general error shape.
