agentsclimarketplace

Attio webhooks events

Skill ComeOnOliver/skillshub/skills/jeremylongshore/claude-code-plugins-plus-skills/attio-webhooks-events

Implement Attio v2 webhooks -- subscribe to record/list/note/task events, verify signatures, filter by object or attribute, and handle idempotently. Trigger: "attio webhook", "attio events", "attio webhook signature", "handle attio events", "attio notifications", "attio real-time".From its SKILL.md

Install
npx -y skills add ComeOnOliver/skillshub --skill attio-webhooks-events

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

What its file declares

Copied from the file, not written here

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

9.7 KB, ~2.3k tokens by cl100k_base, as published. Nobody here has run it

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

CategoryEvent types
Recordrecord.created, record.updated, record.deleted, record.merged
List Entrylist-entry.created, list-entry.updated, list-entry.deleted
Notenote.created, note.updated, note.deleted
Tasktask.created, task.updated, task.deleted
Commentcomment.created, comment.updated, comment.deleted
Listlist.created, list.updated, list.deleted
Object Attributeobject-attribute.created, object-attribute.updated
List Attributelist-attribute.created, list-attribute.updated
Workspace Memberworkspace-member.created, workspace-member.updated
Call Recordingcall-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": ["[email protected]"],
        "name": [{"first_name": "Test", "last_name": "Webhook"}]
      }
    }
  }'

# Remember to delete the test webhook when done

Error Handling

IssueCauseSolution
Signature mismatchWrong secret or body parsed before verificationUse express.raw(), verify raw body
Duplicate eventsNo idempotencyTrack event IDs in Redis/DB
Missed eventsHandler returns non-200Return 200 immediately, process async
Too many eventsNo filteringAdd filters to webhook subscriptions
Webhook deletedAttio cleanup or token revokedRe-register webhook, monitor with health check

Resources

Next Steps

For performance optimization, see attio-performance-tuning.

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.