Receive an HTTPS POST when something changes in your store.
As well as calling the API, you can have Zaapi POST to an HTTPS URL you control when something changes in your store.
The signing secret is shown once, at creation and when you rotate it.
Store it on your server before closing the dialog. Secrets start with
zaapiwh_. The Developers page only shows a masked value
(zaapiwh_••••••••• plus the last four characters).
Store owners and admins can create, edit, rotate, and deactivate webhooks. The Developers page is not shown to other roles.
Rotating issues a new secret immediately. There is no overlap window. Deploy the new secret first, or your endpoint will reject deliveries until it is updated.
You only receive the events you subscribe to.
message.received — a message arrives from a contactmessage.sent — a message is sent from Zaapiticket.opened — a ticket is openedticket.reopened — a ticket is reopenedticket.closed — a ticket is closedticket.assigned — a ticket is assigned to someoneticket.unassigned — a ticket's assignee is removedticket.ticket_field_updated — a ticket field is updatedcontact.contact_field_updated — a contact field is updatedai.escalated — the AI escalates a chatVerify every request before you act on it. Hash the raw request body — the exact bytes you received. Do not parse the JSON and serialise it again.
x-zaapi-signature header. It has the shape
t=<unix>,v1=<hex> with no spaces.t is more than 300 seconds (5 minutes)
ahead of or behind your server clock. A difference of exactly 300
seconds is accepted.{t}.{rawBody}, using
your signing secret as the key. Hex-encode the digest in lowercase.
{rawBody} is the request body as received, with no bytes added or
removed.v1 with a constant-time comparison.
Reject the request if they differ.import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyZaapiWebhook(rawBody, signatureHeader, secret) {
const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(signatureHeader ?? '');
if (!match) return false;
const timestamp = Number(match[1]);
const now = Math.floor(Date.now() / 1000);
if (
!Number.isFinite(timestamp) ||
Math.abs(now - timestamp) > TOLERANCE_SECONDS
) {
return false;
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = Buffer.from(match[2], 'utf8');
const computed = Buffer.from(expected, 'utf8');
return (
received.length === computed.length &&
timingSafeEqual(received, computed)
);
}
rawBody must be the request body as a string or Buffer, taken
before a JSON parser consumes it.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_zaapi_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
try:
parts = dict(item.split('=', 1) for item in signature_header.split(','))
timestamp = int(parts['t'])
received = parts['v1']
except (AttributeError, KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
payload = f'{timestamp}.'.encode('utf-8') + raw_body
expected = hmac.new(secret.encode('utf-8'), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
A delivery succeeds only when your endpoint responds with a 2xx
status (200–299) within 10 seconds. 200, 201, 202, and
204 all count. Return 2xx as soon as you have accepted the event;
do slower work after you respond.
Anything else is a failure and is retried:
3xx, 4xx, and 5xx301 / 302 failIf we time out, we treat the attempt as failed and retry even if you
later return 2xx. Handle duplicates with webhookId.
Automatic retries:
attemptNumber 1).attemptNumber 2).Each retry keeps the same webhookId, eventType,
eventHappenedAt, and data. It increments attemptNumber by 1 and
re-signs the body with a fresh t in x-zaapi-signature. Treat
webhookId as the idempotency key. Do not use attemptNumber to
decide whether you have already processed the event.
If you deactivate the webhook, or unsubscribe from that event, queued retries for it are dropped. If you change the callback URL or rotate the secret, the next attempt uses the live URL and the live secret.
Settings → Developers → Webhooks lists every attempt Zaapi made,
newest first. Each row carries the event type, the attempt number,
when the event happened, when it was sent, whether it succeeded, and
the status code your endpoint returned. Filter by event type, by
success or failure, by date range, or by webhookId to see every
attempt for one event. Open a row to read the exact JSON body that
was sent.
Logs are kept for 7 days, then deleted.
Retry on a row sends that same body again, straight away, without
waiting for the automatic schedule above. The replay carries the next
attemptNumber and a fresh signature, and keeps the same
webhookId — so your endpoint still deduplicates it like any other
retry. It goes to whatever callback URL and secret are live now. Once
any attempt for an event succeeds, manual or automatic, the queued
retries for it stop.
Two cases cannot be replayed. A very large body is not stored with the log, and the row says so — Retry is unavailable on it. A retry is also refused when the webhook is no longer subscribed to that event.
Each delivery is a POST to your callback URL with
Content-Type: application/json. Redirects are not followed. The body
is compact JSON (no extra whitespace):
webhookId — opaque id for this event. Stable across retries.
Deduplicate on it.eventType — one of the events listed above.eventHappenedAt — when the event happened, ISO 8601 UTC.
Omitted when the source event has no time.attemptNumber — 1 on the first POST. Increments by one on
each retry.data — identity fields that were present on the event.
Message events also include message. Other events include
eventData. accountId, conversationId, ticketId, contactId,
and channel are omitted rather than sent as null when they are
not on the event.message.receivedSent when a message arrives from a contact. data.message is a public
message, not the stored channel payload.
{
"webhookId": "<id>",
"eventType": "message.received",
"eventHappenedAt": "<timestamp>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"message": {
"id": "<id>",
"conversationId": "<id>",
"text": "Where is my order?",
"messageType": "text",
"messageCategory": "message",
"messageDirection": "inbound",
"sentAt": "<timestamp>"
}
}
}
message.sentSent when a message is sent from Zaapi. messageDirection is outbound.
{
"webhookId": "<id>",
"eventType": "message.sent",
"eventHappenedAt": "<timestamp>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"message": {
"id": "<id>",
"conversationId": "<id>",
"text": "Your order is on the way",
"messageType": "text",
"messageCategory": "message",
"messageDirection": "outbound",
"sentAt": "<timestamp>"
}
}
}
message — the public message. Omitted fields are left out, not sent as null.text — message text, when the payload has it.messageType — payload type, when it is a known type such as text, image, or email.messageCategory — message, event, or internal-comment, when set.messageDirection — inbound when the contact sent it, outbound when the store sent it.sender — channel sender metadata, when present.sentAt — when the message was sent, in ISO 8601 UTC. eventHappenedAt uses this time.ticket.assignedSent when a ticket is assigned to a user.
{
"webhookId": "<id>",
"eventType": "ticket.assigned",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"assignedAt": "<ISO String>",
"userId": "<id>"
}
}
}
assignedAt — when the ticket was assigned, in ISO 8601 UTC.userId — the ID of the user the ticket was assigned to.ticket.unassignedSent when a ticket's assignee is removed.
{
"webhookId": "<id>",
"eventType": "ticket.unassigned",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"unassignedAt": "<ISO String>",
"ticketNumber": "<string>"
}
}
}
unassignedAt — when the ticket was unassigned, in ISO 8601 UTC.ticketNumber — the human-readable ticket number.ticket.ticket_field_updatedSent when a ticket field is updated.
{
"webhookId": "<id>",
"eventType": "ticket.ticket_field_updated",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"ticketFieldsUpdatedAt": "<ISO String>",
"ticketFields": {
"<id>": "<value>"
}
}
}
}
ticketFieldsUpdatedAt — when the ticket fields were updated, in
ISO 8601 UTC.ticketFields — field values included in the event, keyed by
ticket field ID.ticket.reopenedSent when a ticket is reopened.
{
"webhookId": "<id>",
"eventType": "ticket.reopened",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"ticketNumber": "<string>",
"reopenedAt": "<ISO String>"
}
}
}
ticketNumber — the human-readable ticket number.reopenedAt — when the ticket was reopened, in ISO 8601 UTC.ticket.openedSent when a ticket is opened.
{
"webhookId": "<id>",
"eventType": "ticket.opened",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"ticketNumber": "<string>",
"openedAt": "<ISO String>",
"origin": "<string>",
"isFirstTicket": true
}
}
}
ticketNumber — the human-readable ticket number.openedAt — when the ticket was opened, in ISO 8601 UTC.origin — the source that opened the ticket.isFirstTicket — whether this is the first ticket.ticket.closedSent when a ticket is closed.
{
"webhookId": "<id>",
"eventType": "ticket.closed",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"closedAt": "<ISO String>",
"closedBy": "<id>",
"isClosedByAgent": true,
"isConverted": false,
"resolutionSeconds": 1040952.771
}
}
}
closedAt — when the ticket was closed, in ISO 8601 UTC.closedBy — the ID of the user who closed the ticket.isClosedByAgent — whether an agent closed the ticket.isConverted — whether the conversation led to a conversion.resolutionSeconds — the time to resolution, in seconds.contact.contact_field_updatedSent when a contact field is updated.
{
"webhookId": "<id>",
"eventType": "contact.contact_field_updated",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"contactId": "<id>",
"eventData": {
"contactFields": {
"<id>": "<value>"
},
"contactFieldsUpdatedAt": "<ISO String>"
}
}
}
contactFields — field values included in the event, keyed by
contact field ID.contactFieldsUpdatedAt — when the contact fields were updated,
in ISO 8601 UTC.ai.escalatedSent when the AI escalates a chat.
{
"webhookId": "<id>",
"eventType": "ai.escalated",
"eventHappenedAt": "<ISO String>",
"attemptNumber": 1,
"data": {
"accountId": "<id>",
"conversationId": "<id>",
"ticketId": "<id>",
"channel": "<string>",
"eventData": {
"escalatedAt": "<ISO String>",
"ticketNumber": "<string>"
}
}
}
escalatedAt — when the AI escalated the chat, in ISO 8601 UTC.ticketNumber — the human-readable ticket number.Every attempt includes this header, with no spaces:
x-zaapi-signature: t=1757580000,v1=<hex>
t is Unix time in seconds. v1 is a lowercase hex HMAC-SHA256
digest. Both values change on every attempt.