Skip to content
View as Markdown

Receiving webhooks

Webhooks push events to your server the moment something happens: a customer writes to you, a message is delivered, a contact opts out, a conversation needs a person, a campaign finishes. You add an endpoint (a URL on your server), choose its events, and we POST each one as JSON.

Add an endpoint#

In the portal (Developers → Webhooks) or through the API (POST /webhook_endpoints). Each endpoint has:

  • a public https:// URL;
  • the events it receives: single types, whole families such as contact.*, or * for all;
  • a mode: test endpoints receive only test-mode events, live endpoints only live ones;
  • its own signing secret (whsec_…), shown once.

A workspace can have up to 10 endpoints.

Event families#

FamilyEventsdata.object
message.*received, sent, delivered, read, failedmessage
contact.*created, updated, deleted, opted_out, opted_incontact
conversation.*created, needs_agent, assigned, resolved, reopenedconversation
template.*status_changedtemplate
campaign.*started, paused, resumed, completed, failed, cancelledcampaign
flow.*run.started, run.handed_off, run.completed, run.expired, responseflow run / form response

Every event, with an example, is on Event types. Message and template events leave the moment the change happens. Contact, conversation, campaign and flow events wait about three seconds so they carry the finished object, and several quick changes to one object become one event.

What arrives#

JSON
{
  "id": "evt_01j9x7r2m3n4p5q6r7s8t9v0w1",
  "object": "event",
  "type": "message.delivered",
  "version": "2",
  "created_at": "2026-10-01T09:30:04Z",
  "livemode": true,
  "workspace_id": "ws_12",
  "data": {
    "object": { "id": "msg_48213", "object": "message", "status": "delivered", "reference": "order-10482", "...": "..." },
    "previous_attributes": { "status": "sent" }
  }
}

data.object is exactly what GET /messages/{id} returns, so one parser handles both. With each request come these headers:

HeaderValue
webhook-idThe event id. Identical on every retry: use it to ignore duplicates.
webhook-timestampUnix seconds when this attempt was signed
webhook-signaturev1,<base64 signature> (two, space-separated, during a secret rotation)
X-Automize-EventThe event type, for routing without parsing

Verify every signature#

Anyone can POST to your URL. Before you trust a request, check that it was signed with your endpoint's secret. We follow Standard Webhooks, so the official standardwebhooks libraries (Node, Python, PHP, Go, Ruby, Java, C#, Rust) verify our requests as-is. Or verify by hand:

  1. Build the signed content: {webhook-id}.{webhook-timestamp}.{raw body}. Use the raw body bytes, before any JSON parsing.
  2. Compute HMAC-SHA256 over it with the secret: base64-decode the part after whsec_, use those bytes as the key.
  3. Base64 the result and compare it, in constant time, with each v1,… value in webhook-signature.
  4. Reject timestamps more than 5 minutes from now, to stop replays.
import crypto from "node:crypto";

// Use the RAW request body (a Buffer or string), before any JSON parsing.
export function verifyWebhook(rawBody, headers, secret = process.env.WEBHOOK_SECRET) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"] ?? "";

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay guard

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  // During a secret rotation there are two: "v1,abc v1,def"
  return signatures.split(" ").some((sig) => {
    const [, value = ""] = sig.split(",");
    return value.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}

Answer quickly#

Answer 2xx within 15 seconds to acknowledge. Do the real work (database writes, calling other services) after answering, for example in a queue.

Retries#

Anything other than a 2xx (including timeouts and 4xx) is retried after about 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, with a little jitter. Then the delivery is marked dead. You can replay any delivery from the portal or with POST /webhook_deliveries/{id}/retry.

  • Answer 410 Gone to tell us to stop: the endpoint is disabled.
  • An endpoint whose deliveries have all failed for 3 days is disabled automatically, and the workspace owner is told. Re-enable it once it works.
  • Events can arrive out of order, and more than once. Use webhook-id to de-duplicate and the object's updated_at (or previous_attributes) to keep only the newest state.

Rotating the secret#

Rotate from the portal or with POST /webhook_endpoints/{id}/rotate_secret. For 24 hours every event carries two signatures, old and new, so you can deploy the new secret without dropping events.