Webhooks
קבלו אירועים מ־Zestt בשרת שלכם כבקשות HTTPS חתומות. צרו endpoint, אמתו כל חתימה וטפלו בניסיונות חוזרים ובמשלוחים כפולים.
יצירת endpoint
לפני שמתחילים, צריך:
- מפתח API עם ה־scope
webhooks:manage. - חבילה שכוללת webhooks. ודאו שהשדה
featuresבתשובה שלGET /meכולל אתwebhooks. מספר ה־endpoints שאפשר ליצור תלוי בחבילה. - כתובת בשרת שלכם שמתחילה ב־
https://. כתובתhttp://נדחית.
שלחו POST /webhooks עם הכתובת ועם האירועים שה־endpoint יקבל:
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": "ERP פריוריטי: הזמנות ומסמכים"
}'
{
"id": "wh_01J9KR2B7N",
"url": "https://erp.example.com/zestt/webhooks",
"events": ["order.created", "order.updated", "document.received"],
"status": "active",
"description": "ERP פריוריטי: הזמנות ומסמכים",
"last_delivery_at": null,
"consecutive_failures": 0,
"created_at": "2026-09-11T08:00:00+03:00",
"secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
ה־secret מופיע רק בתשובה הזו. שמרו אותו בכספת הסודות שלכם, ליד מפתח ה־API. בלעדיו אי אפשר לאמת חתימות. אם הוא אבד או נחשף, צרו endpoint חדש ומחקו את הישן.
שליחת אירוע בדיקה
POST /webhooks/{webhook_id}/test שולחת ל־endpoint אירוע ping, חתום כמו כל אירוע אחר, ומחזירה את ניסיון המשלוח עם סטטוס ה־HTTP שה־endpoint שלכם החזיר. סטטוס 2xx מאשר שהכתובת נגישה ושבדיקת החתימה שלכם מקבלת את האירוע.
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_…"
מבנה הבקשה
Zestt שולח כל אירוע כבקשת POST עם גוף JSON ועם כותרת חתימה:
Content-Type: application/json
Zestt-Signature: t=1789103702,v1=…
הגוף תמיד בנוי מאותה מעטפת:
| שדה | תיאור |
|---|---|
id | מזהה ייחודי של האירוע. לפיו מזהים משלוח כפול. |
type | סוג האירוע, מתוך קטלוג האירועים. |
api_version | גרסת ה־API שקובעת את המבנה של data. כרגע "2". |
occurred_at | מתי האירוע קרה, כתאריך ושעה עם הפרש מ־UTC. |
account_id | מזהה החשבון שהאירוע שייך לו. |
data | המשאב כפי שהיה ברגע האירוע. תמיד כולל את object (סוג המשאב) ואת id. |
אירוע 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 הוא תמונת מצב, ולא בהכרח המצב הנוכחי של המשאב. לפני פעולה שתלויה במצב העדכני, למשל אישור הזמנה, קראו את המשאב מחדש עם GET /supplier/orders/{order_id}.
סוגי אירועים ושדות חדשים יכולים להתווסף. התעלמו מסוג או משדה שאתם לא מכירים, והחזירו 2xx כרגיל.
אימות חתימות
כל אחד יכול לשלוח בקשה לכתובת שלכם. החתימה מוכיחה שהבקשה נשלחה מ־Zestt ושהגוף לא שונה בדרך. אמתו אותה בכל בקשה, לפני שאתם משתמשים בתוכן.
בכותרת Zestt-Signature יש שני ערכים: t, זמן החתימה כ־Unix timestamp בשניות, ו־v1, חתימת HMAC-SHA256 בקידוד hex.
- קראו את גוף הבקשה כ־bytes גולמיים, בדיוק כפי שהגיע, לפני כל פענוח JSON.
- פצלו את הכותרת לפי
,, פצלו כל חלק לפי ה־=הראשון, וקחו אתtואתv1. - חשבו HMAC-SHA256 על
{t}.{raw_body}(הערך שלt, נקודה והגוף הגולמי), עם ה־secret של ה־endpoint כמפתח. קודדו את התוצאה ל־hex. - השוו את התוצאה ל־
v1בהשוואה בזמן קבוע (constant-time), לא ב־==. - דחו את הבקשה אם
tרחוק מהשעון שלכם ביותר מ־5 דקות. כך בקשה שהוקלטה לא תתקבל שוב. - פענחו את ה־JSON וטפלו באירוע רק אחרי שכל הבדיקות עברו.
כל אחת מהדוגמאות הבאות היא שרת מלא שאפשר להריץ, שקורא את ה־secret ממשתנה הסביבה ZESTT_WEBHOOK_SECRET.
// 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;
}
הסיבה הנפוצה לחתימה שלא תואמתה־framework פענח את ה־JSON לפני שהקוד שלכם רץ, והחתימה חושבה על JSON שנבנה מחדש. רווח, סדר השדות או קידוד העברית (\u05e9 במקום ש) משנים את ה־bytes, והבדיקה נכשלת. חשבו את החתימה תמיד על הגוף הגולמי.
אם האימות נכשל, החזירו 400 ואל תעבדו את הבקשה. כל תשובה שאינה 2xx נחשבת משלוח שנכשל, ולכן Zestt שולח שוב אירוע אמיתי שנדחה בגלל באג בבדיקה שלכם. ראו טיפול בניסיונות חוזרים ובכפילויות.
בדיקת חתימה
הדביקו משלוח אמיתי כדי לבדוק את החתימה שלו, או חתמו על payload לבדיקה וקבלו פקודת cURL ששולחת אותו ל־endpoint שלכם.
הכל מחושב בדפדפן. ה־secret והגוף לא נשלחים לשום מקום.
טיפול בניסיונות חוזרים ובכפילויות
תשובה מהירה
החזירו 2xx מיד אחרי שאימתתם את החתימה ושמרתם את האירוע, ועבדו אותו ברקע. תשובה שמחכה לעיבוד ארוך ב־ERP עלולה להיחשב משלוח שנכשל, והאירוע יישלח שוב. בנו את ה־handler כך:
- אמתו את החתימה. אם האימות נכשל, החזירו
400. - הכניסו את
idשל האירוע לטבלה עם אילוץ ייחודיות (unique). אם ה־idכבר קיים, האירוע כבר התקבל: החזירו200וסיימו. - הכניסו את האירוע לתור והחזירו
200. - טפלו באירוע מהתור בתהליך ברקע. אם התהליך צריך את המצב העדכני, הוא קורא את המשאב בבקשת GET.
ניסיונות חוזרים והשבתה
- כל תשובה שאינה 2xx נחשבת משלוח שנכשל. Zestt שולח אותו שוב במרווחים שהולכים וגדלים (exponential backoff), עד 8 ניסיונות לאורך כ־24 שעות.
- אחרי 24 שעות של כישלון רצוף, Zestt משבית את ה־endpoint: ה־
statusשלו הופך ל־disabled, ו־Zestt שולח אירועwebhook.disabledומייל.
אחרי שתיקנתם את הבעיה, החזירו את ה־status של ה־endpoint ל־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" }'
אחר כך שלחו שוב את המשלוחים שנכשלו. מצאו אותם עם GET /webhooks/{webhook_id}/deliveries ו־status=failed, ושלחו כל אחד מהם שוב עם POST /webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver.
סדר וכפילויות
- הסדר לא מובטח.
order.updatedיכול להגיע לפניorder.createdשל אותה הזמנה. אל תסיקו מצב מסדר ההגעה. השוו אתoccurred_at, או קראו את המשאב. - אותו אירוע יכול להגיע יותר מפעם אחת, למשל כשהתשובה שלכם לא הגיעה ל־Zestt. שמרו את
idשל כל אירוע, ודלגו על אירוע שה־idשלו כבר שמור אצלכם.
כתובות IP
Zestt שולח webhooks מטווח IP קבוע ומפורסם, ואפשר לאשר אותו בחומת האש. רשימת IP מורשים לא מחליפה את החתימה. אמתו את החתימה בכל בקשה, גם כשהיא מגיעה מכתובת מוכרת.
קטלוג האירועים
בחרו בשדה events של ה־endpoint אילו אירועים הוא יקבל. פיד האירועים משתמש באותו קטלוג. אירועים עם קישור מתוארים במלואם ב־API Reference.
ספקים
| אירוע | מתי הוא נשלח |
|---|---|
order.created | קניין שלח לכם הזמנה חדשה. ההזמנה בסטטוס pending_approval. |
order.updated | השורות, התאריכים או הסטטוס של הזמנה השתנו, כולל ביטול אחרי אישור (cancelled_after_approval). |
order.cancelled | הזמנה בוטלה. |
document.received | הקניין קלט מסמך ששלחתם. data.differences[] מפרט את ההבדלים בין מה ששלחתם למה שנקלט. |
document.disputed | הקניין חלק על מסמך ששלחתם. |
document.approved_for_export | הקניין אישר מסמך ששלחתם לייצוא להנהלת החשבונות. |
buyer.linked | קניין חדש קושר לחשבון שלכם. |
price_list.assigned | מחירון שויך לקניין. |
רשתות וקניינים
| אירוע | מתי הוא נשלח |
|---|---|
order.status_changed | הסטטוס של הזמנה השתנה, למשל כשהספק אישר או דחה אותה. |
document.created | נוסף מסמך רכש: תעודת משלוח, חשבונית, זיכוי או חשבונית מרכזת. |
document.updated | מסמך רכש השתנה. |
expense.created | נוסף מסמך הוצאה. |
expense.approved | מסמך הוצאה אושר. |
journal_lines.approved_for_export | שורות פקודת יומן אושרו לייצוא ומוכנות למשיכה ל־ERP שלכם. |
inventory_count.completed | ספירת מלאי הושלמה. |
supplier.updated | כרטיס ספק השתנה. |
card_transaction.imported | יובאו תנועות כרטיס אשראי. |
כל החשבונות
| אירוע | מתי הוא נשלח |
|---|---|
api_client.suspended | אינטגרציה בחשבון הושעתה, למשל אחרי מעבר לחבילה שלא כוללת גישה ל־API. |
webhook.disabled | endpoint הושבת אחרי 24 שעות של כישלון רצוף. |
ping | קראתם ל־POST /webhooks/{webhook_id}/test כדי לשלוח אירוע בדיקה. |