# Guide to Twenty Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Developers > Webhooks > + Create webhook** |
| Scope | Every create, update and delete for every object, custom objects included |
| Event names | `<objectNameSingular>.<operation>`, such as `person.created` |
| Signature header | `X-Twenty-Webhook-Signature`, bare lowercase hex |
| Timestamp header | `X-Twenty-Webhook-Timestamp`, Unix milliseconds |
| Nonce header | `X-Twenty-Webhook-Nonce`, random and not signed |
| Scheme | HMAC-SHA256, hex |
| Signed content | `<X-Twenty-Webhook-Timestamp>:<JSON body>` |
| Secret | Set on the webhook. Without one, deliveries are unsigned |
| Success | Any 2xx. Non-2xx is logged as a delivery failure |
| Timeout | 5 seconds, from the sender's code |
| Retries, event ID, source IPs | Not documented |

## Common events

| Operation | Example events | Fires when |
| --- | --- | --- |
| Created | `person.created`, `company.created`, `note.created` | A record is created |
| Updated | `person.updated`, `company.updated`, `opportunity.updated` | A record is updated |
| Deleted | `person.deleted`, `company.deleted` | A 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:

```json
{
  "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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Twenty webhooks reliably in minutes.