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

# Webhooks

> Have ICP signal events pushed to your own URL instead of polling for them.

Webhooks push [ICP signals](/guides/icp-signals) to a URL you control, so you find out within
minutes instead of on your next poll.

<Note>
  The feed is still the source of truth. A webhook is a latency convenience on top of
  [List ICP signals](/api-reference/icp-signal-watches/list-icp-signals), and its cursor remains
  the way to catch up on anything a push did not reach you with. Build the poller first; add
  webhooks when the latency matters.
</Note>

## Register an endpoint

```bash theme={null}
curl -X POST -H "Authorization: $TAMTAM_API_KEY" \
  "https://public-api.tamtam.ai/api/v2/webhook-endpoints" \
  -d '{"url":"https://hooks.example.com/tamtam","description":"Salesforce bridge"}'
```

```json theme={null}
{
  "id": "7c1e9a44-2b3d-4e5f-8a9b-0c1d2e3f4a5b",
  "url": "https://hooks.example.com/tamtam",
  "description": "Salesforce bridge",
  "is_enabled": true,
  "secret": "whsec_9f2c7a1e...",
  "created_at": "2026-08-13T09:00:00Z"
}
```

<Warning>
  `secret` is returned **once**, here. It is not readable afterwards from any endpoint. Store it
  before you close the response. If you lose it, delete the endpoint and register it again.
</Warning>

Your URL must be HTTPS and must resolve to a public address. Private, loopback, link-local and
cloud-metadata destinations are refused with a 422 — and checked again at delivery time, because a
hostname that resolves publicly today can be repointed tomorrow. Redirects are not followed.

Registering does **not** replay your history: only events detected after the endpoint exists are
pushed to it. To backfill, walk the feed with its cursor.

## What arrives

```http theme={null}
POST /tamtam HTTP/1.1
Content-Type: application/json
X-Tamtam-Signature: t=1786500000,v1=5a1f...c92
```

```json theme={null}
{
  "type": "icp_signal.detected",
  "delivery_id": "11111111-1111-1111-1111-111111111111",
  "attempt": 1,
  "sent_at": "2026-08-13T09:14:22Z",
  "event": {
    "id": "9b7d5f2e-1c3a-4f8b-9e6d-2a1b3c4d5e6f",
    "icp_criteria_id": "4f1c2a90-6b3e-4d8a-9c7f-1e2d3b4a5c6d",
    "signal_type": "news",
    "company_linkedin_id": "104924588",
    "headline": "Target Co was acquired by Acquirer SA",
    "occurred_at": "2026-08-10T00:00:00Z",
    "detected_at": "2026-08-11T09:14:22Z",
    "payload": { "topic": "m_and_a" }
  }
}
```

`event` is byte-for-byte the object the feed serves, so the same parser handles both.

## Verify the signature

Check it on every request. An unsigned or wrongly-signed request did not come from us.

```
X-Tamtam-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
```

The MAC covers `<t>` + `.` + **the raw request body**. Sign the bytes you received, not a
re-serialization of the parsed JSON — re-encoding can reorder keys and will not match.

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

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, received = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance:
        return False  # too old: a captured request being replayed

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, received)
```

Two details that matter: compare with a constant-time function (`hmac.compare_digest`, not `==`),
and reject an old timestamp. The timestamp is inside the signed string, so an attacker replaying a
captured request cannot rewrite it without breaking the MAC.

## Responding

Answer **2xx** and we consider it delivered. Anything else is a failure.

Answer quickly and do your work afterwards — an attempt times out after 10 seconds. Returning
`202` immediately and queueing internally is the usual shape.

**Be idempotent.** Delivery is at-least-once, like the feed: a push that succeeded on your side but
failed to answer in time will arrive again. Deduplicate on `event.id`, which is stable across
retries and identical to the id on the feed.

## Retries

A failed delivery is retried six times over about 21 hours: after 1 minute, 5 minutes, 25 minutes,
2 hours, 6 hours, then 12 hours. A short outage or a deploy is invisible; an endpoint down
overnight still receives everything.

| Your response                   | What happens                                                              |
| ------------------------------- | ------------------------------------------------------------------------- |
| 2xx                             | `delivered`                                                               |
| 408, 429, 5xx                   | retried, then `exhausted`                                                 |
| Other 4xx                       | `rejected` immediately — a 404 or a failed auth check will not fix itself |
| No response (timeout, DNS, TLS) | retried, then `exhausted`                                                 |

After that the delivery is `exhausted` and stops. Nothing is lost: the event is still on the feed,
and the cursor is how you collect it.

## When something breaks

```bash theme={null}
curl -H "Authorization: $TAMTAM_API_KEY" \
  "https://public-api.tamtam.ai/api/v2/webhook-deliveries?status=exhausted"
```

Each row carries `attempt_count`, `response_status` and a `failure_reason` from a fixed set:
`timeout`, `dns`, `tls`, `connection_refused`, `connection`, `blocked_destination`, `redirect`,
`http_error`.

<Note>
  Your endpoint's **response body is never stored** — only the status code and that reason. The
  body comes from an address you control, so we do not persist what we fetch from it.
</Note>

Once your endpoint is fixed, replay what stopped:

```bash theme={null}
curl -X POST -H "Authorization: $TAMTAM_API_KEY" \
  "https://public-api.tamtam.ai/api/v2/webhook-deliveries/$DELIVERY_ID/replay"
```

Replay re-sends **one delivery**. It does not re-read the feed, so it cannot recover events that
were never queued for this endpoint — anything from before you registered it, or from while it was
disabled. Use the feed's cursor for those.

## Pausing

Set `is_enabled: false` to stop delivery while keeping the endpoint, its secret and its history:

```bash theme={null}
curl -X PATCH -H "Authorization: $TAMTAM_API_KEY" \
  "https://public-api.tamtam.ai/api/v2/webhook-endpoints/$ENDPOINT_ID" \
  -d '{"is_enabled":false}'
```

Events detected while an endpoint is disabled are not queued for it. When you re-enable it, the
recent window is picked up, but anything older is only available from the feed.

<Note>
  A watch in shadow mode (`is_enabled: false` on the
  [ICP signal watch](/api-reference/icp-signal-watches/create-or-update-an-icp-signal-watch))
  never pushes anything, whatever your endpoints are set to. That is what shadow mode is for:
  seeing what an ICP would produce before anyone acts on it.
</Note>
