Attio signs every webhook delivery and sends the signature in an Attio-Signature header. Verification looks trivial — hash the body, compare the hex strings — which is exactly why Attio's own official Node.js example ships a version that throws a runtime error on every single request rather than silently passing or failing. These are the documented patterns and failure modes from Waymark's live route corpus, checked against Attio's own developer documentation.
Part of the webhook verification series — also see: Stripe · Plaid · DocuSign · Typeform
The signature Attio sends is an HMAC-SHA256 digest of the request body — interpreted as a raw UTF-8 string, not a re-serialized JSON object — hex-encoded, and delivered in the Attio-Signature header (duplicated as X-Attio-Signature so legacy middleware that only checks the X--prefixed form still works). As with most webhook providers, the moment a body-parsing middleware turns the request body into a JS object before your handler runs, the exact byte sequence Attio signed is gone. Re-serializing the parsed object produces different bytes — different key order, spacing, or number formatting — so the digest you compute will never match the header, even for a completely legitimate request. Capture the raw body before any JSON-parsing middleware touches it, and hash that.
attio-signature header using a constant-time comparison of equal-length buffers — not string equality, and not raw strings passed to a buffer-only APIIdempotency-Key header since delivery is at-least-once// Node.js/Express — fixed version. Attio's own doc example passes strings
// straight to crypto.timingSafeEqual(), which throws on non-Buffer input.
const crypto = require('crypto');
app.use('/webhook', express.raw({ type: 'application/json' })); // req.body is a raw Buffer
app.post('/webhook', (req, res) => {
const received = req.headers['attio-signature']; // headers arrive lowercased
if (!received) return res.status(403).send('missing signature');
const hmac = crypto.createHmac('sha256', process.env.ATTIO_WEBHOOK_SECRET);
hmac.update(req.body); // raw Buffer — NOT a parsed/re-stringified object
const expected = hmac.digest('hex');
const a = Buffer.from(received, 'hex');
const b = Buffer.from(expected, 'hex');
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.status(403).send('invalid signature');
const payload = JSON.parse(req.body); // safe to parse now that it's verified
res.status(200).send('ok');
// hand payload off to a queue/async worker here, keyed on Idempotency-Key
});
This is the gotcha worth calling out explicitly: Attio's official developer documentation ships a Node.js example that reads const webhookSignature = request.headers["attio-signature"] and const expectedSignature = hmac.digest("hex") — both plain JavaScript strings — and then calls crypto.timingSafeEqual(webhookSignature, expectedSignature) directly on them. Node's crypto.timingSafeEqual only accepts a Buffer, TypedArray, or DataView for both arguments; passed a string, it throws TypeError [ERR_INVALID_ARG_TYPE] immediately. Copy-pasted verbatim, the sample doesn't silently accept a forged signature — it crashes on every single request, valid or not, the first time a real webhook hits the endpoint. Wrap both sides in Buffer.from(str, 'hex') first, and check that the two buffers are the same length before calling timingSafeEqual, since it also throws (rather than returning false) on a length mismatch.
Attio guarantees at-least-once delivery, so network hiccups can produce duplicate sends of the same event; dedupe on the Idempotency-Key header, which stays constant across redeliveries of the same message but differs between distinct events. A delivery only counts as successful on a 2xx response returned within 5 seconds — anything else (a timeout, a 4xx, a 5xx) triggers a retry with exponential backoff, up to 10 attempts spread over roughly 3 days, after which the webhook is marked degraded and Attio emails you. Respond fast and do the real work asynchronously. Separately, Attio rate-limits delivery to each target URL at 25 requests/sec; if your endpoint is a bottleneck under a burst of changes, that's a throughput problem for you to fix, not something Attio will silently drop for you — deliveries queue and smooth out rather than disappearing.
Agents wired to Waymark's MCP server pull this route (and its gotcha — including the timingSafeEqual argument-type crash) at plan time instead of copy-pasting the official example verbatim and shipping code that throws on the first live webhook. Reads are free and keyless.
# Any MCP client — one call, no key:
waymark_query({ "task": "verify attio webhook signature" })
# Or plain HTTP:
curl -s "https://mcp.waymark.network/search?q=verify%20attio%20webhook%20signature"