# Guide to Attio Webhooks: Features and Best Practices

Attio webhooks tell your systems what is changing in your CRM: a person or company is created, a deal moves stage, a record lands on a list, a note or task is written, a comment is resolved. If you sync Attio into a warehouse, trigger automations from CRM activity, or build an integration other Attio workspaces install, webhooks are how those changes reach you without polling the API.

This guide covers how Attio webhooks are created, filtered and signed, the events you can subscribe to and the shape of their payloads, the delivery behaviour that decides when a webhook stops, and the best practices for running an Attio receiver in production.

## What are Attio webhooks?

An Attio webhook is a target URL with one or more subscriptions. Each subscription names an event type, such as `record.created` or `list-entry.updated`, and can carry a filter that narrows which events of that type are sent. When a matching change happens, Attio sends a `POST` to the target URL.

Every delivery is signed. Attio computes an HMAC-SHA256 of the raw request body with the webhook's secret, encodes it as hex, and sends it in the `Attio-Signature` header. The same value is repeated in `X-Attio-Signature` "to support legacy middleware".

Payloads carry IDs, not data. A `record.updated` event tells you which workspace, object, record and attribute changed, and you fetch the new value from the REST API. That keeps payloads small and means your handler always reads current state rather than a snapshot.

## Attio webhook features

| Feature | Details |
| --- | --- |
| Configuration | Developer settings (under an integration), or `POST /v2/webhooks` with the `webhook:read-write` scope |
| Subscription model | One target URL, many subscriptions. Each subscription is an event type and an optional filter |
| Filtering | `$and` / `$or` over payload fields with `equals` and `not_equals`. Set and viewed through the API only |
| Uniqueness | Each target URL, event type and filter combination must be unique in a workspace, or the API returns `409` |
| Signature headers | `Attio-Signature`, duplicated as `X-Attio-Signature` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body, interpreted as UTF-8 |
| Secret | One per webhook. Shown in developer settings, or returned once by the create API |
| Replay window | None. No timestamp is sent or signed |
| Delivery | At least once, with an `Idempotency-Key` header that stays the same across retries |
| Response window | 5 seconds. Any `2xx` is success |
| Retries | Up to 10, with exponential back-off over about 3 days |
| Webhook status | `degraded` after retries are exhausted (with an email), `inactive` after 7 days degraded |
| Rate limit | 25 requests per second per target URL |
| Target URLs | HTTPS only |
| Source IPs | 24 published egress addresses |

Attio publishes its webhook egress addresses but recommends the signature instead of an allowlist, and says it may add new addresses from time to time. The addresses apply to webhook delivery only. Requests from App SDK server functions and workflow blocks come from somewhere else.

## Common events

Attio's V2 webhooks cover 34 event types. Route on `event_type`.

| Area | Events | Fires when |
| --- | --- | --- |
| Records | `record.created`, `record.updated`, `record.deleted`, `record.merged` | A person, company, deal or custom object record is created, has an attribute changed, is deleted, or is merged with another |
| List entries | `list-entry.created`, `list-entry.updated`, `list-entry.deleted` | A record is added to a list, a list attribute on its entry changes, or it is removed |
| Lists | `list.created`, `list.updated`, `list.deleted` | A list is created, renamed or otherwise changed, or deleted |
| Attributes | `object-attribute.created`, `object-attribute.updated`, `list-attribute.created`, `list-attribute.updated` | An attribute is defined or changed on an object or a list |
| Notes | `note.created`, `note.updated`, `note-content.updated`, `note.deleted` | A note is created, has its title changed, has its body changed, or is deleted |
| Tasks | `task.created`, `task.updated`, `task.deleted` | A task is created, changed (such as assignees or deadline), or deleted |
| Comments | `comment.created`, `comment.resolved`, `comment.unresolved`, `comment.deleted` | A comment is created, resolved, un-resolved or deleted |
| Activities | `activity.created`, `activity.updated`, `activity.deleted`, `activity-attribute.created`, `activity-attribute.updated`, `activity-record.created`, `activity-record.deleted` | Activities, their attributes and activity records change |
| Call recordings | `call-recording.created` | A recording has finished processing and its transcript and summary are ready, or have permanently failed |
| Workspace | `workspace-member.created` | A member is added to the workspace |

Two behave differently from what their names suggest. `note.updated` fires only when a note's title changes, and body changes fire `note-content.updated` instead. `record.merged` fires twice for every merge: Attio creates a new record holding both originals' values, and sends one event per original, each with the new record's ID in `id.record_id` and one of the originals in `duplicate_record_id`.

Every delivery has the same envelope. Here is Attio's example for `record.updated`:

```json
{
  "webhook_id": "23e42eaf-323a-41da-b5bb-fd67eebda553",
  "events": [
    {
      "event_type": "record.updated",
      "id": {
        "workspace_id": "14beef7a-99f7-4534-a87e-70b564330a4c",
        "object_id": "97052eb9-e65e-443f-a297-f2d9a4a7f795",
        "record_id": "bf071e1f-6035-429d-b874-d83ea64ea13b",
        "attribute_id": "41252299-f8c7-4b5e-99c9-4ff8321d2f96"
      },
      "actor": {
        "type": "workspace-member",
        "id": "50cf242c-7fa3-4cad-87d0-75b1af71c57b"
      }
    }
  ]
}

```

`webhook_id` names the webhook that triggered the delivery. `events` is an array. Attio says each delivery "contains exactly one event, but this may change in the future to support batching", so loop over it. Inside each event, `id` is a composite of resource IDs that always includes `workspace_id`, and `actor.type` is `api-token`, `workspace-member`, `system` or `app`. Some events add fields beside `id`: `parent_object_id` and `parent_record_id` on list entry and note events, and `duplicate_object_id` and `duplicate_record_id` on `record.merged`.

There is no event ID or timestamp in the body. The per-message ID is the `Idempotency-Key` header.

### Deprecated V1 events

Webhooks created through Attio's V1 API used three event types that Attio now calls deprecated: `entry.created`, `entry-attribute.updated` and `entry.deleted`. They have been replaced by `list-entry.created`, `list-entry.updated` and `list-entry.deleted`, which "fire under exactly the same circumstances". The V1 payloads use different names, such as `collection_id` where V2 has `id.list_id`. Attio says V1 endpoints and event types "will eventually be removed" but gives no date, so move to V2 now.

## Setting up Attio webhooks

From developer settings:

1. Open the developer settings page in your Attio workspace and select an integration, or create one.
2. Create a webhook in the integration and enter your HTTPS target URL.
3. Subscribe to the events you need.
4. Copy the webhook's secret and store it server-side, for example as `ATTIO_WEBHOOK_SECRET`.

From the API, which you need for filters or for an integration that runs in many workspaces:

```bash
curl -X POST "https://api.attio.com/v2/webhooks" \
  -H "Authorization: Bearer $ATTIO_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "target_url": "https://example.com/webhooks/attio",
      "subscriptions": [
        { "event_type": "record.created", "filter": null },
        {
          "event_type": "list-entry.updated",
          "filter": {
            "$and": [
              { "field": "id.list_id", "operator": "equals", "value": "<sales-list-id>" }
            ]
          }
        }
      ]
    }
  }'

```

The response includes the webhook's `secret`, and Attio only shows it there when the webhook is created. Webhooks created with tokens from Attio's OAuth sign-up flow do not appear in developer settings at all, so for those the create response is the only copy. In an integration installed in many workspaces, store one secret per webhook and look it up by the `webhook_id` in the payload.

To test, open the webhook in developer settings, open the dropdown next to a subscribed event and choose **Send test event to target URL**. Attio fills the test payload with real IDs from your workspace where it can, and random data otherwise. Filters are not applied to test events.

## Securing Attio webhooks

Read `Attio-Signature`, compute an HMAC-SHA256 of the raw request body with the webhook secret, encode the result as hex, and compare the two in constant time. Use the secret string exactly as Attio shows it. It is not hex-decoded.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAttioSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/attio', express.raw({ type: '*/*' }), (req, res) => {
  const signature = req.get('attio-signature') ?? req.get('x-attio-signature');
  if (!verifyAttioSignature(req.body, signature, process.env.ATTIO_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const delivery = JSON.parse(req.body.toString('utf8'));
  const idempotency_key = req.get('idempotency-key');
  // Store idempotency_key, then route each item in delivery.events on event_type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_attio_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    # Compare as bytes: compare_digest raises TypeError on a str with non-ASCII characters.
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_attio_signature(raw_body, request.headers.get("attio-signature"),
#                        os.environ["ATTIO_WEBHOOK_SECRET"])

```

Attio's own Node example passes two strings to `crypto.timingSafeEqual`, which only accepts buffers, so it throws as written. Convert both sides to buffers and check their lengths first, as above.

To check your verifier, use the secret `attio-test-webhook-secret` and this exact body:

```
{"webhook_id":"23e42eaf-323a-41da-b5bb-fd67eebda553","events":[{"event_type":"record.created"}]}

```

The signature should be:

```
5f22360ae407fa13735f95a13ead89de367c3614799cfed89250dd10ebbabdc5

```

> **Make Attio webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Attio-Signature` header, answers Attio well inside the 5-second timeout, and durably queues every record, list and note event before your service sees it.

## Attio webhook limitations and pain points

### Retries end with a degraded, then inactive, webhook

**The Problem:** After a long enough outage, the webhook stops receiving events and nothing arrives until someone fixes it.

**Why It Happens:** Any response outside `200`-`299`, or no response within 5 seconds, is a failure. Attio retries up to 10 times with exponential back-off over about 3 days, then marks the webhook `degraded` and emails you. Degraded webhooks still receive events, but one that stays degraded for 7 days becomes `inactive` and receives nothing. Attio does not publish the individual retry intervals.

**Workarounds:**

* Verify, persist and return `200` straight away, then do the work asynchronously.
* Make sure the email Attio sends on degradation reaches someone who will act on it.
* Check webhook status through the API as part of your monitoring.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then retries against your service on a schedule you control and lets you replay any event, so your own downtime does not push the Attio webhook towards `degraded`.

### Payloads carry IDs, so every event costs an API call

**The Problem:** You cannot act on an event without calling Attio back, and a burst of events becomes a burst of API reads.

**Why It Happens:** Payloads hold only IDs and the actor. To know what a record now looks like, you fetch it. Attio also rate-limits delivery to 25 requests per second per target URL, so a bulk import arrives as a long stream rather than a spike.

**Workarounds:**

* Use filters to subscribe only to the lists, attributes or actors you care about.
* Coalesce events per record over a short window and fetch each record once.
* Read `id.attribute_id` on `record.updated` and `list-entry.updated` to skip changes to attributes you ignore.

**How Hookdeck Can Help:** Hookdeck filters can drop events you do not need before they reach your service, and its delivery rate limits stop a large import from overwhelming your own API quota.

### Duplicates are expected

**The Problem:** The same change is processed twice.

**Why It Happens:** Attio guarantees at-least-once delivery and says it "may send duplicate messages" because of network instability. Retries after a timeout are duplicates too, and a duplicated subscription (now blocked by the uniqueness rule) would double every event.

**Workarounds:**

* Store the `Idempotency-Key` header and skip keys you have already processed. Attio keeps it the same across retries and redeliveries.
* Keep handlers idempotent anyway, because a record can change twice in quick succession.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries before they reach your service, based on the fields you choose, such as the `Idempotency-Key` header.

### One secret per webhook

**The Problem:** An integration installed in many workspaces has many secrets, and losing one means recreating the webhook.

**Why It Happens:** Each webhook has its own secret, and for API-created webhooks Attio shows it only in the create response. Attio documents no way to rotate or read back a secret.

**Workarounds:**

* Store the secret against the webhook ID the moment you create it.
* Look up the secret by `webhook_id` from the body, then verify before trusting anything else in the payload.

**How Hookdeck Can Help:** A Hookdeck source holds the secret and verifies each request, so your services trust the source instead of each managing their own copy.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Attio sent. Mount `express.raw()` on the webhook route, use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI, and parse only after verification.

### Loop over `events`

Today there is one event per delivery. Write the handler for many, so batching does not break it when Attio turns it on.

### Filter at the source

Filters run on Attio's side and cut both traffic and API reads. Common patterns are filtering `list-entry.updated` on `id.list_id`, or on `id.attribute_id` for a single status field, and ignoring changes made by your own integration with `actor.type` and `actor.id`.

### Avoid feedback loops

If your integration writes back to Attio, its own changes come back as webhooks too. Every event names who made the change in `actor`, so record the actor your integration writes as and skip, or filter out, events it caused. Otherwise a two-way sync can trigger itself.

### Deduplicate deliveries

Use `Idempotency-Key` as the key. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Five seconds is not much time when every event needs an API read. Verify, persist, return `200`, and fetch from a worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Attio signs every delivery with a hex HMAC-SHA256 of the raw body in `Attio-Signature`, keyed with that webhook's secret. Loop over `events`, fetch the data the IDs point to, deduplicate on `Idempotency-Key`, filter at the source, and answer within five seconds so the webhook never slides from `degraded` to `inactive`.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every `Attio-Signature`, and holds each event until your service has processed it, so an outage on your side costs latency instead of an inactive webhook.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Attio webhooks reliably in minutes.