waymark.networkguides → attio-webhook-signature-verification

Verify Attio webhook signatures

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.

Sourced from 1 live route record · regenerated 2026-07-10

Part of the webhook verification series — also see: Stripe · Plaid · DocuSign · Typeform

Why naive verification silently fails

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.

Documented pattern

Configure and verify Attio webhook signatures

docs.attio.comverified

The verification procedure

  1. Create a webhook via the developer settings page or the Webhooks API, targeting an HTTPS URL — Attio requires HTTPS and won't deliver to plain HTTP
  2. In the handler, capture the raw request body bytes/string before any JSON-parsing middleware runs
  3. Compute HMAC-SHA256 over the raw UTF-8 body using the webhook secret as the key, then hex-encode the digest
  4. Compare the digest to the attio-signature header using a constant-time comparison of equal-length buffers — not string equality, and not raw strings passed to a buffer-only API
  5. Respond 2xx within 5 seconds; deduplicate using the Idempotency-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
});

The crypto.timingSafeEqual bug in Attio's own example

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.

Idempotency, retries, and rate limiting

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.

How agents avoid this automatically

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"
Wire up waymark_query — free → Need a route for your exact stack? $25, delivered in 24h → For teams: verify your whole stack — Pilot $750/mo →