> ## Documentation Index
> Fetch the complete documentation index at: https://docs.memory.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.

## 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`. |

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/anona",
    "event_types": ["memory.created", "memory.consolidated"]
  }'
```

```python Python theme={null}
hook = client.create_webhook(
    space_id="customer-support-bot",
    url="https://example.com/hooks/anona",
    event_types=["memory.created", "memory.consolidated"],
)
store_secret(hook["secret"])  # shown once
```

```typescript TypeScript theme={null}
const hook = await anona.createWebhook({
  spaceId: "customer-support-bot",
  url: "https://example.com/hooks/anona",
  eventTypes: ["memory.created", "memory.consolidated"],
});
storeSecret(hook.secret); // shown once
```

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

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

```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}
```

```typescript TypeScript 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 });
});
```

> [!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.

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

## List webhooks

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

```bash cURL theme={null}
curl https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks \
  -H "Authorization: Bearer anona_live_YOUR_KEY"
```

```python Python theme={null}
hooks = client.list_webhooks("customer-support-bot")
```

```typescript TypeScript theme={null}
const hooks = await anona.listWebhooks("customer-support-bot");
```

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

```bash cURL theme={null}
curl -X PATCH \
  https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks/wh_a1b2c3d4 \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'
```

```python Python theme={null}
hook = client.update_webhook(
    space_id="customer-support-bot",
    webhook_id="wh_a1b2c3d4",
    enabled=False,
)
```

```typescript TypeScript theme={null}
const hook = await anona.updateWebhook({
  spaceId: "customer-support-bot",
  webhookId: "wh_a1b2c3d4",
  enabled: false,
});
```

## Delete a webhook

```bash cURL theme={null}
curl -X DELETE \
  https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks/wh_a1b2c3d4 \
  -H "Authorization: Bearer anona_live_YOUR_KEY"
```

```python Python theme={null}
client.delete_webhook(space_id="customer-support-bot", webhook_id="wh_a1b2c3d4")
```

```typescript TypeScript theme={null}
await anona.deleteWebhook({
  spaceId: "customer-support-bot",
  webhookId: "wh_a1b2c3d4",
});
```

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

## Debugging deliveries

When a receiver is not working, inspect the attempts:

```bash cURL theme={null}
curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks/wh_a1b2c3d4/deliveries?limit=50" \
  -H "Authorization: Bearer anona_live_YOUR_KEY"
```

```python Python theme={null}
page = client.list_webhook_deliveries(
    space_id="customer-support-bot",
    webhook_id="wh_a1b2c3d4",
    limit=50,
)
```

```typescript TypeScript theme={null}
const page = await anona.listWebhookDeliveries({
  spaceId: "customer-support-bot",
  webhookId: "wh_a1b2c3d4",
  limit: 50,
});
```

```json theme={null}
{
  "items": [
    {
      "id": "dlv_1f2e",
      "event_type": "memory.created",
      "url": "https://example.com/hooks/anona",
      "status": "pending",
      "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`. A delivery waiting on its next attempt is `pending` with `next_retry_at` set; `failed` is terminal, reached only after all 6 attempts, and leaves `next_retry_at` null. |
| `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](https://docs.memory.anonalabs.com/api-reference/errors.md) for the general error shape.


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