Gareth Wilson Gareth Wilson

Guide to Attio Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDeveloper settings (under an integration), or POST /v2/webhooks with the webhook:read-write scope
Subscription modelOne 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
UniquenessEach target URL, event type and filter combination must be unique in a workspace, or the API returns 409
Signature headersAttio-Signature, duplicated as X-Attio-Signature
SchemeHMAC-SHA256, hex digest
Signed contentThe raw request body, interpreted as UTF-8
SecretOne per webhook. Shown in developer settings, or returned once by the create API
Replay windowNone. No timestamp is sent or signed
DeliveryAt least once, with an Idempotency-Key header that stays the same across retries
Response window5 seconds. Any 2xx is success
RetriesUp to 10, with exponential back-off over about 3 days
Webhook statusdegraded after retries are exhausted (with an email), inactive after 7 days degraded
Rate limit25 requests per second per target URL
Target URLsHTTPS only
Source IPs24 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.

AreaEventsFires when
Recordsrecord.created, record.updated, record.deleted, record.mergedA person, company, deal or custom object record is created, has an attribute changed, is deleted, or is merged with another
List entrieslist-entry.created, list-entry.updated, list-entry.deletedA record is added to a list, a list attribute on its entry changes, or it is removed
Listslist.created, list.updated, list.deletedA list is created, renamed or otherwise changed, or deleted
Attributesobject-attribute.created, object-attribute.updated, list-attribute.created, list-attribute.updatedAn attribute is defined or changed on an object or a list
Notesnote.created, note.updated, note-content.updated, note.deletedA note is created, has its title changed, has its body changed, or is deleted
Taskstask.created, task.updated, task.deletedA task is created, changed (such as assignees or deadline), or deleted
Commentscomment.created, comment.resolved, comment.unresolved, comment.deletedA comment is created, resolved, un-resolved or deleted
Activitiesactivity.created, activity.updated, activity.deleted, activity-attribute.created, activity-attribute.updated, activity-record.created, activity-record.deletedActivities, their attributes and activity records change
Call recordingscall-recording.createdA recording has finished processing and its transcript and summary are ready, or have permanently failed
Workspaceworkspace-member.createdA 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:

{
  "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:

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.

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:

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

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.

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 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 for free and handle Attio webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.