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:managescope. - A plan that includes webhooks. Check that
featuresin theGET /meresponse containswebhooks. The number of endpoints you can create depends on your plan. - A URL on your server that starts with
https://. Zestt rejectshttp://URLs.
Send POST /webhooks with the URL and the events the endpoint receives:
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"
}'
{
"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 -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:
Content-Type: application/json
Zestt-Signature: t=1789103702,v1=…
Every body uses the same envelope:
| Field | Description |
|---|---|
id | Unique event ID. Use it to detect duplicate deliveries. |
type | Event type, from the event catalog. |
api_version | API version that defines the shape of data. Currently "2". |
occurred_at | When the event occurred, as a datetime with a UTC offset. |
account_id | ID of the account the event belongs to. |
data | The 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:
{
"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.
- Read the request body as raw bytes, exactly as received, before any JSON parsing.
- Split the header on
,, split each part on the first=, and readtandv1. - Compute an HMAC-SHA256 of
{t}.{raw_body}(the value oft, a period, and the raw body) with the endpoint's secret as the key. Hex-encode the result. - Compare the result with
v1using a constant-time comparison, not==. - Reject the request if
tdiffers from your clock by more than 5 minutes. This blocks replays of recorded requests. - 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);# pip install flask
import hashlib
import hmac
import json
import os
import re
import threading
import time
from flask import Flask, request
SECRET = os.environ["ZESTT_WEBHOOK_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify_zestt_signature(raw_body, header, secret):
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or not re.fullmatch("[0-9]+", timestamp) or not signatures:
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed_payload = timestamp.encode("ascii") + b"." + raw_body
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
return any(
hmac.compare_digest(expected.encode("ascii"), sig.lower().encode("utf-8"))
for sig in signatures
)
@app.route("/zestt/webhooks", methods=["POST"])
def zestt_webhooks():
# get_data() returns the body exactly as sent. Do not use request.json here.
raw_body = request.get_data()
header = request.headers.get("Zestt-Signature")
if not verify_zestt_signature(raw_body, header, SECRET):
return "invalid signature", 400
event = json.loads(raw_body)
# Respond first, then process. In production, write the event to a queue or a table.
threading.Thread(target=handle_event, args=(event,), daemon=True).start()
return "ok", 200
def handle_event(event):
print(event["id"], event["type"], event["data"]["id"])
if __name__ == "__main__":
app.run(port=3000)// dotnet new web, then replace Program.cs
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("ZESTT_WEBHOOK_SECRET")
?? throw new InvalidOperationException("ZESTT_WEBHOOK_SECRET is not set"));
app.MapPost("/zestt/webhooks", async (HttpRequest request) =>
{
// Read the raw bytes. Do not bind the body to a model before verifying.
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var rawBody = buffer.ToArray();
var header = request.Headers["Zestt-Signature"].ToString();
if (!VerifyZesttSignature(rawBody, header, secret))
return Results.BadRequest("invalid signature");
using var json = JsonDocument.Parse(rawBody);
var id = json.RootElement.GetProperty("id").GetString();
var type = json.RootElement.GetProperty("type").GetString();
// Respond first, then process. In production, write the event to a queue or a table.
app.Logger.LogInformation("Zestt event {Id} {Type}", id, type);
return Results.Ok();
});
app.Run();
static bool VerifyZesttSignature(byte[] rawBody, string header, byte[] secret)
{
const long toleranceSeconds = 300;
string? timestamp = null;
var signatures = new List<string>();
foreach (var part in header.Split(','))
{
var i = part.IndexOf('=');
if (i < 0) continue;
var key = part[..i].Trim();
var value = part[(i + 1)..].Trim();
if (key == "t") timestamp = value;
else if (key == "v1") signatures.Add(value);
}
if (timestamp is null || signatures.Count == 0 ||
!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var t))
return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > toleranceSeconds)
return false;
var prefix = Encoding.ASCII.GetBytes(timestamp + ".");
var signedPayload = new byte[prefix.Length + rawBody.Length];
prefix.CopyTo(signedPayload, 0);
rawBody.CopyTo(signedPayload, prefix.Length);
var expected = HMACSHA256.HashData(secret, signedPayload);
foreach (var signature in signatures)
{
if (signature.Length != 64) continue;
byte[] received;
try { received = Convert.FromHexString(signature); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(expected, received)) return true;
}
return false;
}
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:
- Verifies the signature. If verification fails, it returns
400. - Inserts the event
idinto a table with a unique constraint. If theidis already there, the event was already received: it returns200and stops. - Adds the event to a queue and returns
200. - 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
statusbecomesdisabled, and Zestt sends awebhook.disabledevent and an email.
After you fix the problem, set the endpoint's status back to active:
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.updatedevent can arrive before theorder.createdevent for the same order. Do not infer state from arrival order. Compareoccurred_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 whoseidyou 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
| Event | Sent when |
|---|---|
order.created | A buyer sends you a new order. The order's status is pending_approval. |
order.updated | An order's lines, dates or status change, including cancellation after approval (cancelled_after_approval). |
order.cancelled | An order is cancelled. |
document.received | The buyer receives a document you sent. data.differences[] lists the differences between what you sent and what was received. |
document.disputed | The buyer disputes a document you sent. |
document.approved_for_export | The buyer approves a document you sent for export to accounting. |
buyer.linked | A new buyer is linked to your account. |
price_list.assigned | A price list is assigned to a buyer. |
Chains and buyers
| Event | Sent when |
|---|---|
order.status_changed | An order's status changes, for example when the supplier confirms or rejects it. |
document.created | A purchase document is added: a delivery note, invoice, credit note or consolidated invoice. |
document.updated | A purchase document changes. |
expense.created | An expense document is added. |
expense.approved | An expense document is approved. |
journal_lines.approved_for_export | Journal lines are approved for export and are ready to pull into your ERP. |
inventory_count.completed | An inventory count is completed. |
supplier.updated | A supplier record changes. |
card_transaction.imported | Card transactions are imported. |
All accounts
| Event | Sent when |
|---|---|
api_client.suspended | An integration in the account is suspended, for example after a change to a plan without API access. |
webhook.disabled | An endpoint is disabled after 24 hours of consecutive failures. |
ping | You call POST /webhooks/{webhook_id}/test to send a test event. |