Zaapi Zaapi Developer
Open Zaapi

Zaapi Webhooks (1.0.0)

Receive an HTTPS POST when something changes in your store.

Webhooks

As well as calling the API, you can have Zaapi POST to an HTTPS URL you control when something changes in your store.

Set up an endpoint

  1. Open the Zaapi App and go to Settings → Developers → Webhooks.
  2. Enter the HTTPS URL Zaapi should POST to. HTTP is rejected.
  3. Tick the events to subscribe to — at least one is required.
  4. Select Create webhook, then copy the signing secret.
  5. Turn the webhook Active. While it is inactive, nothing is sent.

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.

Events

You only receive the events you subscribe to.

Message events

  • message.received — a message arrives from a contact
  • message.sent — a message is sent from Zaapi

Ticket events

  • ticket.opened — a ticket is opened
  • ticket.reopened — a ticket is reopened
  • ticket.closed — a ticket is closed
  • ticket.assigned — a ticket is assigned to someone
  • ticket.unassigned — a ticket's assignee is removed
  • ticket.ticket_field_updated — a ticket field is updated

Contact events

  • contact.contact_field_updated — a contact field is updated

AI events

  • ai.escalated — the AI escalates a chat

Verify the signature

Verify 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.

  1. Read the x-zaapi-signature header. It has the shape t=<unix>,v1=<hex> with no spaces.
  2. Reject the request if the header is missing or does not match that shape.
  3. Reject the request if t is more than 300 seconds (5 minutes) ahead of or behind your server clock. A difference of exactly 300 seconds is accepted.
  4. Compute HMAC-SHA256 over the UTF-8 string {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.
  5. Compare your digest to v1 with a constant-time comparison. Reject the request if they differ.
  6. Only then parse the JSON and handle the event.
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)

Retry behaviour

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:

  • a non-2xx status, including 3xx, 4xx, and 5xx
  • no response within 10 seconds
  • a connection error
  • a redirect — redirects are not followed, so 301 / 302 fail

If we time out, we treat the attempt as failed and retry even if you later return 2xx. Handle duplicates with webhookId.

Automatic retries:

  • The first POST is sent immediately (attemptNumber 1).
  • The first retry waits 45 minutes (attemptNumber 2).
  • Each later wait doubles: 45 minutes, then 90, then 180, and so on.
  • Retries continue for 12 hours after the first attempt. The last wait is shortened so it still falls inside that window.
  • After the window, the event is not sent again.

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.

Delivery logs

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.

Payloads

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.

Messages

message.received

Sent 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.sent

Sent 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.

Tickets

ticket.assigned

Sent 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.unassigned

Sent 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_updated

Sent 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.reopened

Sent 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.opened

Sent 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.closed

Sent 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.

Contacts

contact.contact_field_updated

Sent 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

ai.escalated

Sent 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.

Schemas