Gareth Wilson Gareth Wilson

Guide to Twenty Webhooks: Features and Best Practices

Published


Twenty webhooks tell your systems when your CRM data changes: a person is added, a company is updated, an opportunity moves stage, or a note is deleted. If you run Twenty, the open-source CRM, in the cloud or on your own servers, webhooks are how those record changes reach your data warehouse, your billing system or your own automations without polling the API.

This guide covers how Twenty webhooks are created and signed, the events they send and what the body really looks like, how to verify a delivery, and the practices that keep a Twenty receiver reliable.

What are Twenty webhooks?

A Twenty webhook is a URL in your workspace's settings. Once saved, it receives an HTTP POST whenever a record is created, updated or deleted, for every object type, including custom objects you have added. There is no per-event selection: the docs say "all event types are sent to your webhook URL".

Signing depends on the webhook having a secret. Twenty's sender adds X-Twenty-Webhook-Timestamp and X-Twenty-Webhook-Signature only when a secret is set. The signature is a hex HMAC-SHA256 of the timestamp, a colon and the JSON body. The timestamp is Date.now(), so it is in milliseconds.

Twenty's documentation and its sender code describe the body differently. The docs show event, data and timestamp. The open-source sender posts eventName, record and several other fields. The section on events below covers both.

Twenty webhook features

FeatureDetails
ConfigurationSettings > Developers > Webhooks > + Create webhook
ScopeEvery create, update and delete for every object, custom objects included
Event names<objectNameSingular>.<operation>, such as person.created
Signature headerX-Twenty-Webhook-Signature, bare lowercase hex
Timestamp headerX-Twenty-Webhook-Timestamp, Unix milliseconds
Nonce headerX-Twenty-Webhook-Nonce, random and not signed
SchemeHMAC-SHA256, hex
Signed content<X-Twenty-Webhook-Timestamp>:<JSON body>
SecretSet on the webhook. Without one, deliveries are unsigned
SuccessAny 2xx. Non-2xx is logged as a delivery failure
Timeout5 seconds, from the sender's code
Retries, event ID, source IPsNot documented

Common events

OperationExample eventsFires when
Createdperson.created, company.created, note.createdA record is created
Updatedperson.updated, company.updated, opportunity.updatedA record is updated
Deletedperson.deleted, company.deletedA record is deleted

The object part of the name follows your workspace's data model, so a custom object called invoice produces invoice.created and so on. There is no closed list.

Twenty's docs show this body:

{
  "event": "person.created",
  "data": {
    "id": "abc12345",
    "firstName": "Alice",
    "lastName": "Doe",
    "createdAt": "2025-02-10T15:30:45Z",
    "createdBy": "user_123"
  },
  "timestamp": "2025-02-10T15:30:50Z"
}

Twenty's sender, in call-webhook.job.ts, posts its job data with the secret removed. For record events, that is targetUrl, eventName, objectMetadata (id and nameSingular), workspaceId, webhookId, eventDate, userId, workspaceMemberId, record and updatedFields. So the event name is in eventName and the record in record, not event and data. Log a real delivery from your own Twenty version before you build on either shape, and read the name as body.eventName ?? body.event until you have.

Setting up Twenty webhooks

  1. In Twenty, go to Settings > Developers > Webhooks and click + Create webhook.
  2. Enter your endpoint URL. It must be publicly reachable. Twenty refuses URLs that resolve to private or internal IP addresses.
  3. Set a secret on the webhook and store the same value server-side, for example as TWENTY_WEBHOOK_SECRET. Twenty signs deliveries only when the webhook has a secret.
  4. Click Save. The webhook is active immediately.

To change a webhook, click it, update it and save. To remove it, click it and choose Delete.

If you receive Twenty webhooks through Hookdeck, create a source with the Twenty type and use its URL as the webhook URL. Set a secret on the Twenty webhook (Settings > Developers > Webhooks) and paste it into the source, because Twenty signs deliveries only when the webhook has a secret. The source accepts POST only, which is the method Twenty uses.

Securing Twenty webhooks

Read X-Twenty-Webhook-Timestamp and X-Twenty-Webhook-Signature. Build the signed bytes from the timestamp exactly as received, a literal :, and the raw request body. Compute an HMAC-SHA256 of them with the secret as a UTF-8 key, hex-encode it, and compare it with the header in constant time.

const crypto = require('crypto');
const express = require('express');

function verifyTwentySignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  const signature = signatureHeader.trim();
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. The timestamp is in MILLISECONDS.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() - Number(timestampHeader)) > toleranceSeconds * 1000) return false;
  }

  // A colon, not a dot, between the timestamp and the body.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}:`, 'utf8'), rawBody]))
    .digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/twenty', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.TWENTY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyTwentySignature(
    req.body,
    req.get('x-twenty-webhook-signature'),
    req.get('x-twenty-webhook-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const body = JSON.parse(req.body.toString('utf8'));
  const eventName = body.eventName ?? body.event; // the sender uses eventName; the docs show event
  // Queue the change and acknowledge within Twenty's 5-second timeout.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re
import time


def verify_twenty_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                            tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    signature = signature_header.strip()
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    # Optional replay window, off by default. The timestamp is in milliseconds.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() * 1000 - int(timestamp_header)) > tolerance_seconds * 1000:
            return False
    signed = timestamp_header.encode("utf-8") + b":" + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_twenty_signature(raw_body, request.headers.get("x-twenty-webhook-signature"),
#                         request.headers.get("x-twenty-webhook-timestamp"),
#                         os.environ["TWENTY_WEBHOOK_SECRET"])

Twenty's docs sample signs JSON.stringify(req.body) after a JSON parser has run. That usually reproduces the sender's bytes, because the sender signs JSON.stringify of the payload and posts the same object. It still depends on your parser and serialiser agreeing with Twenty's, so verify the raw body instead. The docs sample also passes the header straight to Buffer.from(..., 'hex') and crypto.timingSafeEqual, which throws when the signature is missing or the wrong length. The samples above check the format first.

Twenty states no tolerance for the timestamp, and X-Twenty-Webhook-Nonce isn't signed, so it can't serve as replay protection. The samples above leave a timestamp check off unless you pass a tolerance. Hookdeck applies no timestamp tolerance, and its retries and replays resend Twenty's original X-Twenty-Webhook-Timestamp, so a short window in your own code would reject a legitimate retry.

Make Twenty webhooks production-ready. Hookdeck Event Gateway verifies the X-Twenty-Webhook-Signature header over the timestamp and raw body, stores every record change, and delivers it to your service with retries you control.

Twenty webhook limitations and pain points

No secret, no signature

The Problem: Requests arrive with no signature headers, and your verifier rejects every one.

Why It Happens: Twenty adds the timestamp, signature and nonce headers only when the webhook has a secret. Creating a webhook with just a URL gives you unsigned deliveries.

Workarounds:

  • Set a secret on every webhook before you rely on it.
  • Treat a delivery without X-Twenty-Webhook-Signature as unauthenticated and reject it.

How Hookdeck Can Help: Hookdeck's Twenty source verifies the signature with the secret you paste. Hookdeck answers Twenty with a 200 either way, then records a request that fails the check, including one with no signature, as failed verification, so it never reaches your destination.

The documented body isn't the body you get

The Problem: A router keyed on event and data never matches anything.

Why It Happens: Twenty's docs show {event, data, timestamp}. The sender posts its job data, with eventName, record, objectMetadata, updatedFields and IDs.

Workarounds:

  • Read eventName first and fall back to event, and record first with data as the fallback.
  • Log a real delivery from your Twenty version and build against it.

How Hookdeck Can Help: Hookdeck stores every request with its full body, so you can see exactly what your Twenty instance sends, and use transformations to reshape it if your handler expects the documented format.

Everything is sent, and the timeout is short

The Problem: Busy workspaces produce a stream of updates your handler can't keep up with.

Why It Happens: Every create, update and delete of every object goes to every webhook, with no filtering yet, and the sender gives up after 5 seconds.

Workarounds:

  • Acknowledge immediately and queue the work.
  • Drop events for objects you don't care about as soon as they are verified.

How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so Twenty's 5-second timeout is never at risk, and filters on eventName can send each object's changes to the service that needs them.

Delivery guarantees are unstated

The Problem: You don't know whether a failed delivery is retried.

Why It Happens: Twenty's docs say non-2xx responses "are logged as delivery failures" and document no retry. The body has no event ID either.

Workarounds:

  • Reconcile against Twenty's API after an outage, using updatedAt on the records you track.
  • Build an idempotency key from the record ID, eventName and eventDate.

How Hookdeck Can Help: Hookdeck stores each delivery, then delivers it to your service with retries you configure, so an outage on your side doesn't depend on whether Twenty retries.

Best practices

Verify against the raw body

The signature covers the bytes Twenty sent. In Express, use express.raw({ type: '*/*' }). In Next.js, use Buffer.from(await request.arrayBuffer()). In FastAPI, use await request.body().

Use updatedFields on updates

The sender includes updatedFields for record events. Use it to skip updates that don't touch fields you sync, rather than re-processing the whole record each time.

Make handlers idempotent

Without a documented event ID, deduplicate on the record ID, the event name and eventDate. See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

Twenty's sender waits 5 seconds. Verify, persist, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Conclusion

Twenty sends every record change to your webhook, and signs it only if you set a secret. Verify X-Twenty-Webhook-Signature as a hex HMAC-SHA256 of the millisecond timestamp, a colon and the raw body, read eventName rather than the documented event, and acknowledge within five seconds.

Hookdeck Event Gateway sits in front of your receiver, verifies every Twenty signature, and holds each record change until your service has processed it.

Get started with Hookdeck for free and handle Twenty 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.