Webhooks
Instead of asking Engage what changed, let Engage tell you: a signed POST to your system the moment a customer writes, a message is read, or a sale is marked.
Owners only
Adding, changing and removing webhooks needs the owner role - an endpoint receives your customers' messages. Set them up in Settings → Webhooks, or through the API.
Add an endpoint
POST /v1/webhooks
{
"url": "https://crm.example.com/engage-events",
"description": "Our CRM",
"events": ["message.received", "sale.created"]
}The address must be https on the public internet - a host name, not an IP address. The answer includes secret, shown this once: keep it in your receiver to check signatures. Up to 10 endpoints per workspace.
| Field | Type | Notes |
|---|---|---|
message.received | event | A customer sent a message. data: conversationId, customerId, from, type, text, sentAt. |
message.status | event | A message you sent was delivered, read or failed. data: conversationId, providerMessageId, status, detail. |
sale.created | event | A conversation was marked as a sale. data: saleId, conversationId, customerId, amount, currency, note. |
customer.opted_out | event | A customer said STOP. data: channel, handle, scope, reason. |
campaign.completed | event | A campaign finished. data: campaignId, name, sent, skipped, unknown. |
What arrives
POST to your address
X-Engage-Event: message.received
X-Engage-Event-Id: 5b1f0c2e-6a4d-4c1e-9d0f-2f8a7c3b9e11
X-Engage-Delivery: 0e9d7a41-3c2b-4f6e-8a1d-5b4c3a2f1e0d
X-Engage-Signature: t=1789812000,v1=5f2b...
{
"id": "5b1f0c2e-6a4d-4c1e-9d0f-2f8a7c3b9e11",
"type": "message.received",
"createdAt": "2026-09-19T10:40:00Z",
"workspaceId": "…",
"data": { "conversationId": "…", "from": "2348156218098", "text": "Is the blue bag still available?" }
}Answer with any 2xx within 10 seconds. Anything else - or no answer - is tried again after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours, then marked failed; you can retry a failed delivery from the dashboard. Redirects are not followed.
Deliveries can repeat - use the event id
Engage sends at least once: a timeout after your system has already saved the event means it arrives again. Store id (also in X-Engage-Event-Id) and ignore one you have already handled. Order is not guaranteed either - compare createdAt if it matters.
Check the signature
v1 is the HMAC-SHA256, keyed with your secret, of the timestamp, a dot, and the raw request body. Compute it over the body exactly as received - before any JSON parsing - and reject a timestamp more than five minutes old, so a captured request cannot be replayed.
Node.js
import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Test it
POST /v1/webhooks/{id}/test - or Send a test in the dashboard - sends a ping event to that endpoint whatever it subscribed to. GET /v1/webhooks/{id}/deliveries lists the last 50, with the status code or error of each.
Next
Errors →
What every error response looks like.