Webhooks

Get a signed request on your server when contacts change and emails are delivered, opened, clicked, bounce or are marked as spam.

Webhooks let your own app follow what happens in Zeebra without asking: when a contact unsubscribes, Zeebra sends your server a request at once, so your records stay in step. Zeebra stays the one place that decides who may be emailed.

Add an endpoint

Workspace owners and admins add endpoints in Settings → Webhooks (or with create_webhook in the API). An endpoint is an https:// URL on your server and the events it wants. A workspace can have up to 10.

When you add one, Zeebra shows its signing secret (whsec_…) once. Store it with your app's other secrets. New signing secret makes another; for 24 hours after that, deliveries are signed with both, so you can switch without missing an event.

Send a test event sends a webhook.test event straight away and shows what your server answered. Each endpoint has a delivery log of its last 100 deliveries: the event, your server's status code and answer, how long it took and which attempt it was, with Resend.

Events

Event When
contact.created A contact was added: by hand, an import, a signup form or the API.
contact.updated A contact's details or status changed (changed lists what).
contact.unsubscribed A contact unsubscribed from all email (previous_status, via).
contact.opted_in A contact agreed to get email: a signup, an import, or marked by your team (via).
email.delivered A campaign email reached the recipient's mail server.
email.opened A recipient opened a campaign email for the first time. Machine opens (Apple Mail Privacy Protection, scanners) never count.
email.clicked A recipient clicked a link in a campaign email for the first time (url). Bot clicks never count.
email.bounced An email bounced (bounce.type is permanent, transient or undetermined, with the receiving server's diagnostic). Only permanent bounces stop the contact.
email.complained A recipient marked an email as spam.
campaign.sent A campaign finished sending, or was published on your public pages only.

Choose Every event to get all of them, including events added later. Replies aren't an event: replies go straight to your sender's own mailbox, so Zeebra never sees them.

What Zeebra sends

A POST with a JSON body:

{
    "id": "evt_01jd2v7k3m9q8x4r5t6y7z8a9b",
    "type": "contact.unsubscribed",
    "created_at": "2026-11-05T09:30:00+00:00",
    "workspace_id": 7,
    "data": {
        "contact": {
            "id": 1234,
            "email": "sara@example.com",
            "status": "unsubscribed",
            "…": "…"
        },
        "previous_status": "active",
        "via": "unsubscribe_link"
    }
}

data uses the same shapes as the API: a contact as get_contact returns it, a campaign as get_campaign does (without its content), and an email like list_contact_emails (with to and its campaign), plus a contact when Zeebra knows them.

The headers:

  • Zeebra-Signature: t=1762335000,v1=5257a869… (see below)
  • Zeebra-Event-Id and Zeebra-Event-Type: the event's id and type
  • Zeebra-Delivery-Id: this delivery
  • User-Agent: Zeebra-Webhooks/1.0

Answer with any 2xx status within 10 seconds. Do slow work after answering (on a queue). Zeebra doesn't follow redirects.

Check the signature

v1 is the HMAC-SHA256, in hex, of the timestamp t, a dot, and the raw request body, keyed with your signing secret. To check a request:

  1. Read t and every v1 from the Zeebra-Signature header.
  2. Compute HMAC-SHA256(secret, t + "." + raw_body) in hex.
  3. Accept the request when it equals one of the v1 values (compare in constant time) and t is within 5 minutes of now.

Use the raw body exactly as it arrived, before any JSON parsing.

PHP:

function zeebraSignatureIsValid(string $header, string $body, string $secret): bool
{
    preg_match('/(?:^|,)\s*t=(\d+)/', $header, $time);
    preg_match_all('/v1=([a-f0-9]{64})/', $header, $signatures);
    $timestamp = (int) ($time[1] ?? 0);
    $expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);

    if (abs(time() - $timestamp) > 300) {
        return false;
    }
    foreach ($signatures[1] as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

Node.js:

import crypto from 'node:crypto';

function zeebraSignatureIsValid(header, rawBody, secret) {
    const parts = header.split(',').map((part) => part.trim().split('='));
    const timestamp = Number(parts.find(([key]) => key === 't')?.[1]);
    const signatures = parts
        .filter(([key]) => key === 'v1')
        .map(([, value]) => value);
    const expected = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${rawBody}`)
        .digest('hex');

    if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300)
        return false;
    return signatures.some(
        (signature) =>
            signature.length === expected.length &&
            crypto.timingSafeEqual(
                Buffer.from(signature),
                Buffer.from(expected),
            ),
    );
}

Retries and switching off

A delivery that doesn't get a 2xx (an error status, no answer within 10 seconds, or no connection) is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, six attempts in all. The delivery log says when the next try is.

The same event can arrive more than once (a retry after your server answered too late, or a Resend). Use the event's id to handle each event once. Events can also arrive out of order; use created_at and the record's own dates.

An endpoint that fails 20 times in a row over at least a day is switched off, and the workspace's owners and admins get an email. Events that happen while it is off aren't sent. Fix your server, send a test event, then switch it back on (this clears its failures).

Safety

Webhooks only go to public https:// addresses. Zeebra looks up your host before every delivery and refuses private, local and reserved addresses, so an endpoint can't reach a private network.

With the API

list_webhook_event_types, list_webhooks, get_webhook, create_webhook, update_webhook, delete_webhook, rotate_webhook_secret, test_webhook, list_webhook_deliveries and resend_webhook_delivery do the same from code, the CLI or an AI agent. See the API reference.