Attio Webhooks & Events
Overview
Attio v2 webhooks deliver real-time event notifications to your HTTPS endpoint. You can subscribe to specific event types and filter by object, list, or attribute to reduce volume. Webhooks are managed via POST /v2/webhooks and verified with HMAC-SHA256 signatures.
Prerequisites
- HTTPS endpoint accessible from the internet
- Scopes:
webhook:read-write
ATTIO_WEBHOOK_SECRET stored securely
Attio Webhook Event Types
| Category |
Event types |
| Record |
record.created, record.updated, record.deleted, record.merged |
| List Entry |
list-entry.created, list-entry.updated, list-entry.deleted |
| Note |
note.created, note.updated, note.deleted |
| Task |
task.created, task.updated, task.deleted |
| Comment |
comment.created, comment.updated, comment.deleted |
| List |
list.created, list.updated, list.deleted |
| Object Attribute |
object-attribute.created, object-attribute.updated |
| List Attribute |
list-attribute.created, list-attribute.updated |
| Workspace Member |
workspace-member.created, workspace-member.updated |
| Call Recording |
call-recording.created, call-recording.updated |
Instructions
Step 1: Create a Webhook Subscription
// Register webhook with event filtering
const webhook = await client.post<{
data: {
id: { workspace_id: string; webhook_id: string };
target_url: string;
subscriptions: Array<{ event_type: string; filter?: object }>;
created_at: string;
};
}>("/webhooks", {
target_url: "https://yourapp.com/api/webhooks/attio",
subscriptions: [
// All record events
{ event_type: "record.created" },
{ event_type: "record.updated" },
{ event_type: "record.deleted" },
// Only deal list-entry events (filtered)
{
event_type: "list-entry.created",
filter: { list: { $eq: "sales_pipeline" } },
},
// Only updates to a specific attribute
{
event_type: "record.updated",
filter: {
$and: [
{ object: { $eq: "deals" } },
{ attribute: { $eq: "stage" } },
],
},
},
// Notes and tasks
{ event_type: "note.created" },
{ event_type: "task.created" },
],
});
console.log("Webhook ID:", webhook.data.id.webhook_id);
Step 2: List and Manage Webhooks
// List all webhooks
const webhooks = await client.get<{
data: Array<{
id: { webhook_id: string };
target_url: string;
subscriptions: Array<{ event_type: string }>;
}>;
}>("/webhooks");
// Get a specific webhook
const wh = await client.get(`/webhooks/${webhookId}`);
// Update webhook subscriptions
await client.patch(`/webhooks/${webhookId}`, {
subscriptions: [
{ event_type: "record.created" },
{ event_type: "record.updated" },
],
});
// Delete a webhook
await client.delete(`/webhooks/${webhookId}`);
Step 3: Webhook Endpoint with Signature Verification
import express from "express";
import crypto from "crypto";
const app = express();
// CRITICAL: Use raw body for signature verification
app.post(
"/api/webhooks/attio",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["x-attio-signature"] as string;
const timestamp = req.headers["x-attio-timestamp"] as string;
// 1. Verify signature
if (!verifyAttioWebhook(req.body, signature, timestamp)) {
console.error("Webhook signature verification failed");
return res.status(401).json({ error: "Invalid signature" });
}
// 2. Parse event
const event = JSON.parse(req.body.toString());
// 3. Return 200 immediately (Attio expects fast response)
res.status(200).json({ received: true });
// 4. Process asynchronously
try {
await processEvent(event);
} catch (err) {
console.error("Event processing failed:", event.event_type, err);
}
}
);
function verifyAttioWebhook(
rawBody: Buffer,
signature: string,
timestamp: string
): boolean {
const secret = process.env.ATTIO_WEBHOOK_SECRET!;
// Reject timestamps older than 5 minutes (replay protection)
const age = Date.now() - parseInt(timestamp) * 1000;
if (age > 300_000) {
console.error("Webhook timestamp too old:", age, "ms");
return false;
}
// Compute expected signature
const payload = `${timestamp}.${rawBody.toString()}`;
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false;
}
}
Step 4: Event Handler with Type Routing
interface AttioWebhookEvent {
event_type: string;
id: { event_id: string };
created_at: string;
actor: { type: string; id: string };
object?: { id: { object_id: string }; api_slug: string };
record?: { id: { record_id: string } };
list?: { id: { list_id: string }; api_slug: string };
entry?: { id: { entry_id: string } };
// Additional fields vary by event type
}
type EventHandler = (event: AttioWebhookEvent) => Promise<void>;
const handlers: Record<string, EventHandler> = {
"record.created": async (event) => {
const objectSlug = event.object?.api_slug;
const recordId = event.record?.id?.record_id;
console.log(`New ${objectSlug} record: ${recordId}`);
if (objectSlug === "people") {
// Fetch full record details
const person = await client.get(
`/objects/people/records/${recordId}`
);
await syncPersonToExternalSystem(person);
}
},
"record.updated": async (event) => {
console.log(`Updated ${event.object?.api_slug}: ${event.record?.id?.record_id}`);
// Re-sync updated record
},
"record.deleted": async (event) => {
console.log(`Deleted record: ${event.record?.id?.record_id}`);
// Remove from external system
},
"record.merged": async (event) => {
console.log(`Records merged into: ${event.record?.id?.record_id}`);
// Update references in external system
},
"list-entry.created": async (event) => {
console.log(`New entry in ${event.list?.api_slug}: ${event.entry?.id?.entry_id}`);
// Trigger pipeline automation
},
"note.created": async (event) => {
console.log("New note created");
// Sync to external note system
},
"task.created": async (event) => {
console.log("New task created");
// Create corresponding task in project management tool
},
};
async function processEvent(event: AttioWebhookEvent): Promise<void> {
const handler = handlers[event.event_type];
if (!handler) {
console.log(`Unhandled event type: ${event.event_type}`);
return;
}
await handler(event);
}
Step 5: Idempotent Event Processing
// Deduplicate events using a Set, Redis, or database
const processedEvents = new Set<string>();
async function processEventIdempotently(event: AttioWebhookEvent): Promise<void> {
const eventId = event.id.event_id;
if (processedEvents.has(eventId)) {
console.log(`Duplicate event skipped: ${eventId}`);
return;
}
await processEvent(event);
processedEvents.add(eventId);
// Clean up old entries (keep last 10,000)
if (processedEvents.size > 10_000) {
const entries = Array.from(processedEvents);
entries.slice(0, entries.length - 10_000).forEach((id) => processedEvents.delete(id));
}
}
// For production, use Redis:
// await redis.set(`attio:event:${eventId}`, "1", "EX", 86400 * 7);
Step 6: Local Webhook Testing
# Expose local server with ngrok
ngrok http 3000
# Register ngrok URL as webhook target
curl -X POST https://api.attio.com/v2/webhooks \
-H "Authorization: Bearer ${ATTIO_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://abc123.ngrok.io/api/webhooks/attio",
"subscriptions": [{"event_type": "record.created"}]
}'
# Create a test record to trigger the webhook
curl -X POST https://api.attio.com/v2/objects/people/records \
-H "Authorization: Bearer ${ATTIO_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"data": {
"values": {
"email_addresses": ["webhook-test@example.com"],
"name": [{"first_name": "Test", "last_name": "Webhook"}]
}
}
}'
# Remember to delete the test webhook when done
Error Handling
| Issue |
Cause |
Solution |
| Signature mismatch |
Wrong secret or body parsed before verification |
Use express.raw(), verify raw body |
| Duplicate events |
No idempotency |
Track event IDs in Redis/DB |
| Missed events |
Handler returns non-200 |
Return 200 immediately, process async |
| Too many events |
No filtering |
Add filters to webhook subscriptions |
| Webhook deleted |
Attio cleanup or token revoked |
Re-register webhook, monitor with health check |
Resources
Next Steps
For performance optimization, see attio-performance-tuning.
1---2name: attio-webhooks-events3description: |4 Implement Attio v2 webhooks -- subscribe to record/list/note/task events,5 verify signatures, filter by object or attribute, and handle idempotently.6 Trigger: "attio webhook", "attio events", "attio webhook signature",7 "handle attio events", "attio notifications", "attio real-time".8allowed-tools: Read, Write, Edit, Bash(curl:*)9version: 1.0.010license: MIT11author: Jeremy Longshore <jeremy@intentsolutions.io>12tags: [saas, crm, attio]13compatible-with: claude-code14---151617# Attio Webhooks & Events1819## Overview2021Attio v2 webhooks deliver real-time event notifications to your HTTPS endpoint. You can subscribe to specific event types and filter by object, list, or attribute to reduce volume. Webhooks are managed via `POST /v2/webhooks` and verified with HMAC-SHA256 signatures.2223## Prerequisites2425- HTTPS endpoint accessible from the internet26- Scopes: `webhook:read-write`27- `ATTIO_WEBHOOK_SECRET` stored securely2829## Attio Webhook Event Types3031| Category | Event types |32|----------|------------|33| Record | `record.created`, `record.updated`, `record.deleted`, `record.merged` |34| List Entry | `list-entry.created`, `list-entry.updated`, `list-entry.deleted` |35| Note | `note.created`, `note.updated`, `note.deleted` |36| Task | `task.created`, `task.updated`, `task.deleted` |37| Comment | `comment.created`, `comment.updated`, `comment.deleted` |38| List | `list.created`, `list.updated`, `list.deleted` |39| Object Attribute | `object-attribute.created`, `object-attribute.updated` |40| List Attribute | `list-attribute.created`, `list-attribute.updated` |41| Workspace Member | `workspace-member.created`, `workspace-member.updated` |42| Call Recording | `call-recording.created`, `call-recording.updated` |4344## Instructions4546### Step 1: Create a Webhook Subscription4748```typescript49// Register webhook with event filtering50const webhook = await client.post<{51 data: {52 id: { workspace_id: string; webhook_id: string };53 target_url: string;54 subscriptions: Array<{ event_type: string; filter?: object }>;55 created_at: string;56 };57}>("/webhooks", {58 target_url: "https://yourapp.com/api/webhooks/attio",59 subscriptions: [60 // All record events61 { event_type: "record.created" },62 { event_type: "record.updated" },63 { event_type: "record.deleted" },6465 // Only deal list-entry events (filtered)66 {67 event_type: "list-entry.created",68 filter: { list: { $eq: "sales_pipeline" } },69 },7071 // Only updates to a specific attribute72 {73 event_type: "record.updated",74 filter: {75 $and: [76 { object: { $eq: "deals" } },77 { attribute: { $eq: "stage" } },78 ],79 },80 },8182 // Notes and tasks83 { event_type: "note.created" },84 { event_type: "task.created" },85 ],86});8788console.log("Webhook ID:", webhook.data.id.webhook_id);89```9091### Step 2: List and Manage Webhooks9293```typescript94// List all webhooks95const webhooks = await client.get<{96 data: Array<{97 id: { webhook_id: string };98 target_url: string;99 subscriptions: Array<{ event_type: string }>;100 }>;101}>("/webhooks");102103// Get a specific webhook104const wh = await client.get(`/webhooks/${webhookId}`);105106// Update webhook subscriptions107await client.patch(`/webhooks/${webhookId}`, {108 subscriptions: [109 { event_type: "record.created" },110 { event_type: "record.updated" },111 ],112});113114// Delete a webhook115await client.delete(`/webhooks/${webhookId}`);116```117118### Step 3: Webhook Endpoint with Signature Verification119120```typescript121import express from "express";122import crypto from "crypto";123124const app = express();125126// CRITICAL: Use raw body for signature verification127app.post(128 "/api/webhooks/attio",129 express.raw({ type: "application/json" }),130 async (req, res) => {131 const signature = req.headers["x-attio-signature"] as string;132 const timestamp = req.headers["x-attio-timestamp"] as string;133134 // 1. Verify signature135 if (!verifyAttioWebhook(req.body, signature, timestamp)) {136 console.error("Webhook signature verification failed");137 return res.status(401).json({ error: "Invalid signature" });138 }139140 // 2. Parse event141 const event = JSON.parse(req.body.toString());142143 // 3. Return 200 immediately (Attio expects fast response)144 res.status(200).json({ received: true });145146 // 4. Process asynchronously147 try {148 await processEvent(event);149 } catch (err) {150 console.error("Event processing failed:", event.event_type, err);151 }152 }153);154155function verifyAttioWebhook(156 rawBody: Buffer,157 signature: string,158 timestamp: string159): boolean {160 const secret = process.env.ATTIO_WEBHOOK_SECRET!;161162 // Reject timestamps older than 5 minutes (replay protection)163 const age = Date.now() - parseInt(timestamp) * 1000;164 if (age > 300_000) {165 console.error("Webhook timestamp too old:", age, "ms");166 return false;167 }168169 // Compute expected signature170 const payload = `${timestamp}.${rawBody.toString()}`;171 const expected = crypto172 .createHmac("sha256", secret)173 .update(payload)174 .digest("hex");175176 // Timing-safe comparison177 try {178 return crypto.timingSafeEqual(179 Buffer.from(signature),180 Buffer.from(expected)181 );182 } catch {183 return false;184 }185}186```187188### Step 4: Event Handler with Type Routing189190```typescript191interface AttioWebhookEvent {192 event_type: string;193 id: { event_id: string };194 created_at: string;195 actor: { type: string; id: string };196 object?: { id: { object_id: string }; api_slug: string };197 record?: { id: { record_id: string } };198 list?: { id: { list_id: string }; api_slug: string };199 entry?: { id: { entry_id: string } };200 // Additional fields vary by event type201}202203type EventHandler = (event: AttioWebhookEvent) => Promise<void>;204205const handlers: Record<string, EventHandler> = {206 "record.created": async (event) => {207 const objectSlug = event.object?.api_slug;208 const recordId = event.record?.id?.record_id;209 console.log(`New ${objectSlug} record: ${recordId}`);210211 if (objectSlug === "people") {212 // Fetch full record details213 const person = await client.get(214 `/objects/people/records/${recordId}`215 );216 await syncPersonToExternalSystem(person);217 }218 },219220 "record.updated": async (event) => {221 console.log(`Updated ${event.object?.api_slug}: ${event.record?.id?.record_id}`);222 // Re-sync updated record223 },224225 "record.deleted": async (event) => {226 console.log(`Deleted record: ${event.record?.id?.record_id}`);227 // Remove from external system228 },229230 "record.merged": async (event) => {231 console.log(`Records merged into: ${event.record?.id?.record_id}`);232 // Update references in external system233 },234235 "list-entry.created": async (event) => {236 console.log(`New entry in ${event.list?.api_slug}: ${event.entry?.id?.entry_id}`);237 // Trigger pipeline automation238 },239240 "note.created": async (event) => {241 console.log("New note created");242 // Sync to external note system243 },244245 "task.created": async (event) => {246 console.log("New task created");247 // Create corresponding task in project management tool248 },249};250251async function processEvent(event: AttioWebhookEvent): Promise<void> {252 const handler = handlers[event.event_type];253 if (!handler) {254 console.log(`Unhandled event type: ${event.event_type}`);255 return;256 }257 await handler(event);258}259```260261### Step 5: Idempotent Event Processing262263```typescript264// Deduplicate events using a Set, Redis, or database265const processedEvents = new Set<string>();266267async function processEventIdempotently(event: AttioWebhookEvent): Promise<void> {268 const eventId = event.id.event_id;269270 if (processedEvents.has(eventId)) {271 console.log(`Duplicate event skipped: ${eventId}`);272 return;273 }274275 await processEvent(event);276 processedEvents.add(eventId);277278 // Clean up old entries (keep last 10,000)279 if (processedEvents.size > 10_000) {280 const entries = Array.from(processedEvents);281 entries.slice(0, entries.length - 10_000).forEach((id) => processedEvents.delete(id));282 }283}284285// For production, use Redis:286// await redis.set(`attio:event:${eventId}`, "1", "EX", 86400 * 7);287```288289### Step 6: Local Webhook Testing290291```bash292# Expose local server with ngrok293ngrok http 3000294295# Register ngrok URL as webhook target296curl -X POST https://api.attio.com/v2/webhooks \297 -H "Authorization: Bearer ${ATTIO_API_KEY}" \298 -H "Content-Type: application/json" \299 -d '{300 "target_url": "https://abc123.ngrok.io/api/webhooks/attio",301 "subscriptions": [{"event_type": "record.created"}]302 }'303304# Create a test record to trigger the webhook305curl -X POST https://api.attio.com/v2/objects/people/records \306 -H "Authorization: Bearer ${ATTIO_API_KEY}" \307 -H "Content-Type: application/json" \308 -d '{309 "data": {310 "values": {311 "email_addresses": ["webhook-test@example.com"],312 "name": [{"first_name": "Test", "last_name": "Webhook"}]313 }314 }315 }'316317# Remember to delete the test webhook when done318```319320## Error Handling321322| Issue | Cause | Solution |323|-------|-------|----------|324| Signature mismatch | Wrong secret or body parsed before verification | Use `express.raw()`, verify raw body |325| Duplicate events | No idempotency | Track event IDs in Redis/DB |326| Missed events | Handler returns non-200 | Return 200 immediately, process async |327| Too many events | No filtering | Add filters to webhook subscriptions |328| Webhook deleted | Attio cleanup or token revoked | Re-register webhook, monitor with health check |329330## Resources331332- [Attio Webhooks Guide](https://docs.attio.com/rest-api/guides/webhooks)333- [Attio Webhook Events Reference](https://docs.attio.com/rest-api/webhook-reference/record-events/recordcreated)334- [Attio List Webhooks](https://docs.attio.com/rest-api/endpoint-reference/webhooks/list-webhooks)335336## Next Steps337338For performance optimization, see `attio-performance-tuning`.339