Webhooks

Receive Zestt events on your server as signed HTTPS requests. Create an endpoint, verify each signature, and handle retries and duplicate deliveries.

Create an endpoint

Before you start, you need:

  • An API key with the webhooks:manage scope.
  • A plan that includes webhooks. Check that features in the GET /me response contains webhooks. The number of endpoints you can create depends on your plan.
  • A URL on your server that starts with https://. Zestt rejects http:// URLs.

Send POST /webhooks with the URL and the events the endpoint receives:

cURL
curl "https://sandbox-api.zester.co.il/v2https://api.zester.co.il/v2/webhooks" \
  -H "Authorization: Bearer zk_test_XXXXXXXX_…zk_live_XXXXXXXX_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.example.com/zestt/webhooks",
    "events": ["order.created", "order.updated", "document.received"],
    "description": "Priority ERP: orders and documents"
  }'
201 Created
{
  "id": "wh_01J9KR2B7N",
  "url": "https://erp.example.com/zestt/webhooks",
  "events": ["order.created", "order.updated", "document.received"],
  "status": "active",
  "description": "Priority ERP: orders and documents",
  "last_delivery_at": null,
  "consecutive_failures": 0,
  "created_at": "2026-09-11T08:00:00+03:00",
  "secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}

The secret appears only in this response. Store it in your secret manager, next to your API key. You need it to verify signatures. If the secret is lost or exposed, create a new endpoint and delete the old one.

Send a test event

POST /webhooks/{webhook_id}/test sends a ping event to the endpoint, signed like any other event, and returns the delivery attempt with the HTTP status your endpoint returned. A 2xx status confirms that the URL is reachable and that your signature check accepts the event.

cURL
curl -X POST "https://sandbox-api.zester.co.il/v2https://api.zester.co.il/v2/webhooks/wh_01J9KR2B7N/test" \
  -H "Authorization: Bearer zk_test_XXXXXXXX_…zk_live_XXXXXXXX_…"

Request format

Zestt delivers each event as a POST request with a JSON body and a signature header:

Headers
Content-Type: application/json
Zestt-Signature: t=1789103702,v1=…

Every body uses the same envelope:

FieldDescription
idUnique event ID. Use it to detect duplicate deliveries.
typeEvent type, from the event catalog.
api_versionAPI version that defines the shape of data. Currently "2".
occurred_atWhen the event occurred, as a datetime with a UTC offset.
account_idID of the account the event belongs to.
dataThe resource as it was at the time of the event. Always includes object (the resource type) and id.

An order.created event for a new order sent to a supplier:

order.created
{
  "id": "evt_01J9KQ7X4M9C",
  "type": "order.created",
  "api_version": "2",
  "occurred_at": "2026-09-11T08:15:00+03:00",
  "account_id": "72223",
  "data": {
    "object": "order",
    "id": "2335619",
    "number": "71148-80",
    "status": "pending_approval",
    "buyer": { "id": "71148", "name": "שניצי קפה", "customer_number": "777077070" },
    "supplier": { "id": "72223", "name": "מאפייה אחת עשרה" },
    "branch": { "id": "71148-1", "name": "שניצי קפה – ראשי" },
    "sent_at": "2026-09-11T08:15:00+03:00",
    "delivery_date": "2026-09-14",
    "currency": "ILS",
    "totals": { "before_vat": "138.00", "vat": "24.84", "with_vat": "162.84" },
    "lines": [
      { "id": "l1", "sku": "300", "name": "בייבי ג'בטה לבן", "quantity": "1", "unit": "carton", "unit_price": "80.00", "total": "80.00" },
      { "id": "l2", "sku": "412", "name": "לחמניית חיטה מלאה", "quantity": "2", "unit": "carton", "unit_price": "29.00", "total": "58.00" }
    ],
    "notes": null,
    "created_at": "2026-09-11T08:15:00+03:00",
    "updated_at": "2026-09-11T08:15:00+03:00"
  }
}

data is a snapshot, not necessarily the current state of the resource. Before an action that depends on the latest state, such as confirming an order, retrieve the resource again with GET /supplier/orders/{order_id}.

New event types and new fields can be added. Ignore any type or field you do not recognize, and return 2xx as usual.

Verify signatures

Anyone can send a request to your URL. The signature proves that the request came from Zestt and that the body was not modified in transit. Verify it on every request, before you act on the body.

The Zestt-Signature header has two values: t, the signing time as a Unix timestamp in seconds, and v1, the HMAC-SHA256 signature as a hex string.

  1. Read the request body as raw bytes, exactly as received, before any JSON parsing.
  2. Split the header on ,, split each part on the first =, and read t and v1.
  3. Compute an HMAC-SHA256 of {t}.{raw_body} (the value of t, a period, and the raw body) with the endpoint's secret as the key. Hex-encode the result.
  4. Compare the result with v1 using a constant-time comparison, not ==.
  5. Reject the request if t differs from your clock by more than 5 minutes. This blocks replays of recorded requests.
  6. Parse the JSON and handle the event only after all checks pass.

Each example below is a complete, runnable server that reads the secret from the ZESTT_WEBHOOK_SECRET environment variable.

// npm install express
const crypto = require("node:crypto");
const express = require("express");

const SECRET = process.env.ZESTT_WEBHOOK_SECRET;
if (!SECRET) throw new Error("ZESTT_WEBHOOK_SECRET is not set");
const TOLERANCE_SECONDS = 300;

function verifyZesttSignature(rawBody, header, secret) {
  if (typeof header !== "string") return false;

  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t") timestamp = value;
    else if (key === "v1") signatures.push(value);
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();

  return signatures.some(
    (sig) => /^[0-9a-f]{64}$/i.test(sig) && crypto.timingSafeEqual(expected, Buffer.from(sig, "hex"))
  );
}

const app = express();

// express.raw() keeps the body as a Buffer, byte for byte.
// Do not put express.json() in front of this route.
app.post("/zestt/webhooks", express.raw({ type: "*/*" }), (req, res) => {
  const header = req.get("Zestt-Signature");
  if (!Buffer.isBuffer(req.body) || !verifyZesttSignature(req.body, header, SECRET)) {
    return res.status(400).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  res.status(200).send("ok");

  // Respond first, then process. In production, write the event to a queue or a table.
  setImmediate(() => handleEvent(event));
});

function handleEvent(event) {
  console.log(event.id, event.type, event.data.id);
}

app.listen(3000);

The most common cause of a signature mismatchYour framework parsed the JSON before your code ran, and the signature was computed over re-serialized JSON. Whitespace, field order or the encoding of Hebrew characters (\u05e9 instead of ש) change the bytes, and the check fails. Always compute the signature over the raw body.

If verification fails, return 400 and do not process the request. Any response other than 2xx is a failed delivery, so Zestt retries a genuine event that a bug in your check rejected. See Handle retries and duplicates.

Test a signature

Paste a real delivery to check its signature, or sign a test payload and get a cURL command that sends it to your endpoint.

Signatures are computed in your browser. The secret and the body never leave this page.

Handle retries and duplicates

Respond quickly

Return 2xx as soon as you have verified the signature and stored the event, then process it in the background. A response that waits for slow processing in your ERP can count as a failed delivery, and the event is sent again. A handler that follows this pattern:

  1. Verifies the signature. If verification fails, it returns 400.
  2. Inserts the event id into a table with a unique constraint. If the id is already there, the event was already received: it returns 200 and stops.
  3. Adds the event to a queue and returns 200.
  4. Processes the event from the queue in a background worker. If the worker needs the current state, it retrieves the resource with a GET request.

Retries and disabled endpoints

  • Any response other than 2xx is a failed delivery. Zestt retries it with exponential backoff, up to 8 attempts over about 24 hours.
  • After 24 hours of consecutive failures, Zestt disables the endpoint: its status becomes disabled, and Zestt sends a webhook.disabled event and an email.

After you fix the problem, set the endpoint's status back to active:

cURL
curl -X PATCH "https://sandbox-api.zester.co.il/v2https://api.zester.co.il/v2/webhooks/wh_01J9KR2B7N" \
  -H "Authorization: Bearer zk_test_XXXXXXXX_…zk_live_XXXXXXXX_…" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'

Then resend the deliveries that failed. Find them with GET /webhooks/{webhook_id}/deliveries and status=failed, and resend each one with POST /webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver.

Ordering and duplicates

  • Events can arrive out of order. An order.updated event can arrive before the order.created event for the same order. Do not infer state from arrival order. Compare occurred_at, or retrieve the resource.
  • An event can arrive more than once, for example when your response did not reach Zestt. Store each event id, and skip any event whose id you have already stored.

IP addresses

Zestt sends webhooks from a fixed, published IP range, which you can allow in your firewall. An IP allowlist does not replace the signature. Verify the signature on every request, including requests from known addresses.

Event catalog

Choose the events an endpoint receives in its events field. The event feed uses the same catalog. Linked events are documented in full in the API reference.

Suppliers

EventSent when
order.createdA buyer sends you a new order. The order's status is pending_approval.
order.updatedAn order's lines, dates or status change, including cancellation after approval (cancelled_after_approval).
order.cancelledAn order is cancelled.
document.receivedThe buyer receives a document you sent. data.differences[] lists the differences between what you sent and what was received.
document.disputedThe buyer disputes a document you sent.
document.approved_for_exportThe buyer approves a document you sent for export to accounting.
buyer.linkedA new buyer is linked to your account.
price_list.assignedA price list is assigned to a buyer.

Chains and buyers

EventSent when
order.status_changedAn order's status changes, for example when the supplier confirms or rejects it.
document.createdA purchase document is added: a delivery note, invoice, credit note or consolidated invoice.
document.updatedA purchase document changes.
expense.createdAn expense document is added.
expense.approvedAn expense document is approved.
journal_lines.approved_for_exportJournal lines are approved for export and are ready to pull into your ERP.
inventory_count.completedAn inventory count is completed.
supplier.updatedA supplier record changes.
card_transaction.importedCard transactions are imported.

All accounts

EventSent when
api_client.suspendedAn integration in the account is suspended, for example after a change to a plan without API access.
webhook.disabledAn endpoint is disabled after 24 hours of consecutive failures.
pingYou call POST /webhooks/{webhook_id}/test to send a test event.