# Twenty Webhooks

## When to Use This Skill

* How do I receive Twenty (open-source CRM) webhooks?
* How do I verify the `X-Twenty-Webhook-Signature` header?
* How do I handle `person.created`, `company.updated` or `opportunity.updated` events?
* How do I receive webhooks for custom objects in Twenty?
* Why is my Twenty webhook signature verification failing?

> Set a secret on the Twenty webhook (Settings > Developers > Webhooks). Twenty signs deliveries only when the webhook has a secret.

## Verification (core)

Every delivery is a `POST` with `X-Twenty-Webhook-Signature`, `X-Twenty-Webhook-Timestamp` and `X-Twenty-Webhook-Nonce`. The signature is the **bare lowercase hex HMAC-SHA256** (no prefix), keyed with the webhook secret (its UTF-8 bytes, used as-is), over the timestamp header value, a literal **colon**, then the **raw request body**. The nonce is sent but not signed.

```javascript
const crypto = require('crypto');

function verifyTwentySignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // validate before decoding

  const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from(':'), rawBody]); // bytes, never a re-encoded string
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(signature.toLowerCase(), 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(expected, received);
}

```

Respond **401** for a missing, malformed or mismatched signature and any **2xx** (the examples use `200`) for a valid delivery. Read the raw body first (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`, `await request.body()`) and parse JSON only after the check. Twenty's docs example signs `JSON.stringify(req.body)`; Twenty's sender signs the JSON string it posts, so signing the raw body is equivalent and avoids re-serialisation drift.

**Timestamp window is off by default.** `X-Twenty-Webhook-Timestamp` is `Date.now()` (Unix **milliseconds**). Hookdeck retries and replays resend the original headers and timestamp (and Hookdeck applies no tolerance itself), so a fixed window would reject legitimate retries. The examples offer an opt-in: set `TWENTY_WEBHOOK_TOLERANCE_SECONDS=300` to enforce one (unset or `0` = off). Use idempotency instead (see below).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/twenty-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/twenty-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/twenty-webhooks/examples/fastapi/).

## Common Event Types

Twenty sends one event per record change, for every object including custom objects. Dispatch on the body's `eventName` (`<object>.<action>`); Twenty's docs page shows the same value in a field named `event`, so the examples read `eventName` and fall back to `event`.

| Event | Triggered When |
| --- | --- |
| `person.created` | A person record is created |
| `person.deleted` | A person record is deleted |
| `company.updated` | A company record is updated |
| `opportunity.updated` | An opportunity record is updated |
| `note.created` | A note is created |

The same `.created`, `.updated` and `.deleted` actions fire for every other object (e.g. `company.created`, `opportunity.deleted`, or your custom objects). All events go to every webhook URL; there is no event filter, so ignore the ones you don't need.

Twenty's docs say a 2xx acknowledges a delivery and non-2xx is logged as a failure; the sender's HTTP timeout is 5 seconds and retries are not documented.

## Environment Variables

```bash
# Secret set on the webhook in Twenty (Settings > Developers > Webhooks)
TWENTY_WEBHOOK_SECRET=your_twenty_webhook_secret
# Optional: reject deliveries older than N seconds (unset or 0 = off)
TWENTY_WEBHOOK_TOLERANCE_SECONDS=0

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 twenty --path /webhooks/twenty

```

Use the printed public HTTPS URL as the webhook URL in Twenty (Settings > Developers > Webhooks).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/twenty-webhooks/references/overview.md) - How Twenty webhooks work, events, payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/twenty-webhooks/references/setup.md) - Create the webhook and set its secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/twenty-webhooks/references/verification.md) - Signature verification details and gotchas