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

# Webhooks

> Receive every submission in your own stack, signed

Outgoing webhooks push each tool submission to your server as it happens, signed
with an HMAC so you can prove it came from us. Same shape as Stripe's: a
timestamp and a signature in one header, a tolerance window on your side.

Deliveries are retried with exponential backoff, and every attempt is inspectable
after the fact — including a manual redelivery.

## Events

| Event                | Fired when                                                                  |
| -------------------- | --------------------------------------------------------------------------- |
| `submission.created` | a tool ran and produced a submission (chat, voice, API, or the form widget) |
| `submission.updated` | a submission changed status (`NEW` → `CONTACTED` → `RESOLVED`)              |

An endpoint receives only the events it subscribes to.

## POST /tenants/{slug}/webhooks

Register an endpoint.

**Required scope:** `settings:write`

```bash theme={null}
curl -X POST https://api.animam.ai/tenants/my-company/webhooks \
  -H "Authorization: Bearer ak_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/hooks/animam",
    "description": "CRM production",
    "events": ["submission.created", "submission.updated"],
    "piiFields": []
  }'
```

```json theme={null}
{
  "endpoint": {
    "id": "3f1c…",
    "url": "https://your-server.com/hooks/animam",
    "description": "CRM production",
    "events": ["submission.created", "submission.updated"],
    "enabled": true,
    "piiFields": [],
    "createdAt": "2026-07-30T09:12:44.000Z"
  },
  "secret": "9a1b…",
  "warning": "Store this secret — it will not be shown again."
}
```

<Warning>
  The signing secret is returned **once**, at creation. We store a copy to sign
  with, but the API never returns it again — if you lose it, delete the endpoint
  and create a new one.
</Warning>

The `url` must be publicly resolvable `https`. It is checked against an SSRF
guard (DNS resolved, private ranges refused) at registration.

## The payload

```json theme={null}
{
  "event": "submission.created",
  "createdAt": "2026-07-30T11:24:50.912Z",
  "data": {
    "id": "b2d0…",
    "toolId": "9c31…",
    "toolType": "SUBMIT_FORM",
    "toolName": "contact_form",
    "channel": "web",
    "status": "NEW",
    "input": { "name": "Marie Durand", "email": "marie@example.com", "message": "Is the house still available?" },
    "result": { "success": true, "message": "Request captured." },
    "conversationId": "c-42",
    "messages": [
      { "role": "user", "content": "Is the house still available?" },
      { "role": "assistant", "content": "An offer may have been accepted since — let me have someone call you back." }
    ],
    "visitorUserId": null,
    "pageUrl": "https://your-site.com/listings/42",
    "createdAt": "2026-07-30T11:24:50.900Z"
  }
}
```

`input` holds the fields your tool declared, under **your** field names.
`channel` is `web`, `voice`, `api` (an agent called the API) or `form` (the
standalone form widget).

### `messages` — the conversation, when we already have it

The last 10 turns of the conversation that produced the submission, so you don't
have to call back to find out what the visitor actually asked.

It is present when the turns are in memory at delivery time — the chat path.
It is absent for the form widget (`channel: "form"`, there is no conversation)
and may be absent on other channels: we do not add a database read to a
fire-and-forget path that runs for every tool execution. **Treat it as optional**
and fall back to `conversationId` +
[GET /tenants/{slug}/conversations/{id}](/api-reference/endpoints/conversations)
when you need the full thread.

Only `user` and `assistant` turns are included — never system prompts or tool
calls.

### Keeping visitor PII off the wire

`piiFields` lists keys to remove from `data.input` before signing and sending —
for example `["visitorEmail", "visitorPhone"]` if your CRM must not receive
contact details over this channel. Max 32 entries.

<Note>
  Setting **any** `piiFields` also removes `messages` entirely. A transcript is
  free text: the visitor typed their email into it, so redacting a key while
  shipping the conversation verbatim would defeat the request. The unredacted
  payload is still kept in our database for your audit trail — only the wire is
  trimmed.
</Note>

## Verifying the signature

Every request carries three headers:

| Header                 | Value                                        |
| ---------------------- | -------------------------------------------- |
| `X-Animam-Signature`   | `t=<unix-seconds>,v1=<hex>`                  |
| `X-Animam-Event`       | the event name                               |
| `X-Animam-Delivery-Id` | the delivery id, for idempotency and support |

`v1` is `HMAC_SHA256(secret, "<t>.<raw request body>")`, hex-encoded. Sign the
**raw** body — re-serializing your parsed JSON will not match.

Reject the request if `|now - t| > 300` seconds: without that check, a captured
request can be replayed forever.

```python theme={null}
import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = int(parts["t"]), parts["v1"]
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
```

```javascript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(secret, rawBody, header, tolerance = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.v1 ?? '', 'hex')
  return a.length === b.length && timingSafeEqual(a, b)
}
```

## Delivery, retries and timeouts

Answer with any `2xx` and quickly: **the request is aborted after 5 seconds**.
Do the work after acknowledging, not before.

A non-`2xx`, a timeout or a connection error schedules a retry. Six attempts
total, spaced **30 s, 2 min, 10 min, 1 h, 6 h** — about 8 hours of tolerance for
an endpoint that is down. After the last failure the delivery is marked `failed`
and stays inspectable.

Retries mean the same event can arrive twice (for instance if your `2xx` was lost
on the way back). Deduplicate on `X-Animam-Delivery-Id`, or on `data.id` if you
prefer to key on the submission.

## GET /tenants/{slug}/webhooks

List endpoints. Secrets are never included. **Scope:** `settings:read`

## GET /tenants/{slug}/webhooks/{id}

One endpoint, with its recent delivery stats. **Scope:** `settings:read`

## PATCH /tenants/{slug}/webhooks/{id}

Change `url`, `description`, `events`, `enabled` or `piiFields`.
**Scope:** `settings:write`

Disabling an endpoint (`"enabled": false`) stops delivery immediately; deliveries
already pending are dropped rather than queued forever.

## DELETE /tenants/{slug}/webhooks/{id}

Remove the endpoint and its delivery history. **Scope:** `settings:write`

## GET /tenants/{slug}/webhooks/{id}/deliveries

The delivery log: status, attempt count, HTTP status, last error, timestamps.
This is where you look when your endpoint says nothing arrived.
**Scope:** `settings:read`

## POST /tenants/{slug}/webhooks/{id}/deliveries/{deliveryId}/redeliver

Queue a past delivery again — same payload, freshly signed (the timestamp moves,
so the old signature would not have verified anyway). Use it after fixing your
receiver rather than asking a visitor to submit again.
**Scope:** `settings:write`
