Empirical Docs / Integrations

Webhooks.

Have Empirical call your app when an item or note in a workspace changes: signed JSON events, retries, and the checks your receiver should make. For developers on the Builder plan and above.

What a webhook does and who can use it

A webhook makes Empirical call your app when something changes in a workspace. Give it an https address and each time an item is created, changes status, is edited, or gets a note, Empirical sends a signed JSON event there. Use it to keep a CRM in step, post to chat, or start a build. Webhooks need the workspace owner on the Builder plan or above (team plans included), and only the workspace's owners and managers can manage them; in a personal workspace that is you.

  • The plan that counts is the workspace owner's. If it lapses, nothing new is sent and queued events are dropped; you can still list and remove
  • A webhook belongs to its creator. If they stop being an owner or manager, it is turned off
  • Webhooks are not a way to read old data: a webhook receives only events from after it was created or last turned on, and nothing is replayed

Set one up

Add a webhook from the CLI, over REST, or by asking an agent that has the empirical_command tool (webhook.add). The signing secret is in the reply and is shown once: store it before anything else. If you lose it, rotate it. Through an agent the secret lands in that conversation, so prefer the CLI for the first setup if that matters to you. Then send a test ping and read the delivery log.

  • url must be https and resolve only to public addresses; no user name or password; redirects are never followed
  • At most 10 webhooks per workspace, one per url
  • events defaults to item.created, item.status_changed, item.updated and note.added
  • itemTypes limits it to items of those types (the keys shape.show lists); leave it out for every item
empirical webhook add https://example.com/hooks/empirical --events item.status_changed,note.added --item-types deal
empirical webhook test <id>
empirical webhook deliveries <id>

# REST
curl -X POST https://empirical.gauzza.com/api/v1/webhooks -H "Authorization: Bearer $EMPIRICAL_ACCESS_TOKEN" -H "Content-Type: application/json" -d '{"workspaceId":"ws_team_8f2a41c0","url":"https://example.com/hooks/empirical","events":["item.status_changed"]}'

# Agent
empirical_command({ command: "webhook.add", args: { url: "https://example.com/hooks/empirical", events: ["item.status_changed"] } })

The events

Four events come from real changes, and ping comes only from a test. Item deletions are not sent. A webhook filtered by item type also hears when an item leaves that type, so a host that follows that type learns it is gone. data.changedFields can appear on any item event and lists keys, never values. Check it before you treat item.created as new: an item moved in from another workspace arrives as item.created with changedFields: ["workspaceId"], and a deleted item that is restored arrives as item.created with changedFields: ["active"]. Only an item.created with no changedFields is a genuinely new item; a host that counts every item.created would count a restored item twice.

  • item.created: an item is created, moved in from another workspace (changedFields: ["workspaceId"]) or restored (changedFields: ["active"])
  • item.status_changed: an item moves to another status; data.previousStatus holds the old one and data.item.status the new one, each as { key, label }. changedFields appears when the same write also changed fields
  • item.updated: an item's fields or type change; data.changedFields lists the field keys (not the values), and data.previousItemType appears when the type changed
  • note.added: a note is added to an item, with changedFields: ["notes"]. It does not carry a note id or the note text today; read the item through the API if you need the note
  • ping: sent only by a test
  • data.item.status and data.item.type can be null: no status (No stage) or no type (Unsorted). When a manager removes a status, each item that had it arrives as item.status_changed with data.item.status null and the removed one in previousStatus; when a type is removed, its items arrive with data.item.type null and the removed type in previousItemType

What arrives

Every delivery is an HTTP POST with a JSON body and three Empirical headers: X-Empirical-Signature (t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" under your secret>), X-Empirical-Event (the event type), and X-Empirical-Delivery (a delivery id plus the attempt number; it changes on every retry, so do not dedupe on it). The body's id is the event id: it stays the same on every retry and duplicate. data.item.summary is workspace content (null once the item has left the workspace), so your endpoint now holds a copy. On servers in HIPAA mode, payloads carry ids only; fetch the item through the API for details. There the body keeps the event id, type and time, the workspace id, the actor, the item id, its type and status (key and label, and the previous ones) and the changedFields keys, and has no summary, field values or note text. actor says who made the change: type is user, api_key or system; an api_key actor also carries onBehalfOf, and a change an agent made for a person over MCP carries via. label is a display name, never an email address.

{
  "id": "evt_9b1c4e7a2d3f4a5b6c7d8e9f",
  "type": "item.status_changed",
  "createdAt": "2026-11-04T15:32:07.412Z",
  "workspaceId": "ws_team_8f2a41c0",
  "actor": {
    "type": "user",
    "id": "u_ana_41c2d9",
    "label": "Ana Ruiz"
  },
  "data": {
    "item": {
      "id": "6728f1c2a9b3d4e5f6a7b8c9",
      "type": {
        "key": "deal",
        "label": "Deal"
      },
      "status": {
        "key": "won",
        "label": "Won"
      },
      "summary": "Acme annual contract, 40 seats"
    },
    "previousStatus": {
      "key": "negotiating",
      "label": "Negotiating"
    }
  }
}

Verify the signature

Check the signature on the raw body before you parse it. Re-serializing parsed JSON changes the bytes and the check fails. This verifier compares in constant time and rejects a timestamp more than five minutes from your clock, which stops a captured request being replayed.

const crypto = require('crypto');

// rawBody: the request body exactly as received (Buffer or string). Never re-serialize parsed JSON.
function verify(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(signatureHeader || '').split(',').map((p) => p.trim().split('=')));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  const given = Buffer.from(String(parts.v1 || ''));
  const wanted = Buffer.from(expected);
  return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}

// Express: use express.raw, not express.json
app.post('/hooks/empirical', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-Empirical-Signature'), process.env.EMPIRICAL_WEBHOOK_SECRET)) return res.sendStatus(401);
  res.sendStatus(200); // answer first, process after
  queueForProcessing(JSON.parse(req.body.toString('utf8')));
});

Boundary: A webhook is signed, not encrypted beyond https. Anyone who holds the secret can sign events, so keep it in your secret store and out of logs.

Delivery, retries and de-duplication

Each attempt has a 10 second limit; answer with any 2xx status to mark it delivered, and answer fast: accept the event, then process it in the background. Redirects are not followed, and a 3xx counts as a failure. A failed attempt is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then the delivery is failed. Events can arrive more than once (a server restart during a deploy repeats one) and out of order (a retried event lands after a newer one), so de-duplicate on the event id and use createdAt or the item's current state when order matters. Delivery rows are kept for 30 days.

  • pending: queued. sending: an attempt is in progress. retrying: an attempt failed and the next is scheduled. delivered: your endpoint answered 2xx. failed: all six attempts failed
  • dropped: closed without being sent and not counted against your endpoint, because the webhook was removed or turned off, the event predates the last time it was turned on, the owner's plan no longer includes webhooks, or the creator is no longer a manager. lastError says which
  • After 20 failed attempts in a row over at least 24 hours the webhook turns itself off and its creator gets one email. One success resets the count
  • To turn it back on, fix the endpoint and run empirical webhook update <id> --enable. That clears the count. Turning a webhook off, by you or automatically, drops its queued events; they are not replayed
empirical webhook deliveries <id> --limit 20
empirical webhook update <id> --enable

Do not loop on your own writes

If your app writes back to Empirical in response to an event, that write produces another event. Ignore the events your own writes made, by actor. Which actor depends on how your app signs in: with an API key, actor.type is api_key and actor.id is the key's actor id (onBehalfOf names the key's owner); with an OAuth token or the CLI signed in as a person, actor.type is user and actor.id is that person's user id, with no onBehalfOf (a change an agent makes for a person over MCP is also user, with via naming the client). Learn your own id by making one write as your app and reading actor.id from the delivery, or from a test ping. Name an API key after your integration so actor.label reads clearly.

// mine: { keyActorId: 'key_...' } and/or { userId: 'u_...' }, whichever identities your app writes as
function isMine(event, mine) {
  const a = event.actor || {};
  return (a.type === 'api_key' && a.id === mine.keyActorId) || (a.type === 'user' && a.id === mine.userId);
}

Boundary: A user match hides every change that person makes, including their own edits in the portal, so use it only for a dedicated account that your app alone signs in as. If you must share a person's identity, tell your writes apart another way, such as a field your app sets.

Rotate the secret

rotate-secret replaces the signing secret at once and shows the new one once. There is no overlap window: the next delivery is signed with the new secret only, so a receiver that still has the old one rejects it. A delivery that fails while you switch is retried and signed with the new secret, so it is not lost. The safe order is to deploy a receiver that accepts both secrets, rotate, then remove the old one.

// 1. receiver accepts both
const ok = [process.env.WEBHOOK_SECRET_NEW, process.env.WEBHOOK_SECRET_OLD].filter(Boolean)
  .some((secret) => verify(raw, header, secret));

// 2. rotate (previews first; --apply --yes applies it)
empirical webhook rotate-secret <id> --apply --yes

// 3. set the new secret, check deliveries verify, then drop the old one

Related: Agents can run the same operations with the empirical_command tool: webhook.add, webhook.list, webhook.update, webhook.remove, webhook.test, webhook.deliveries and webhook.rotate_secret.

Last updated: November 4, 2026