SLK Invoices POSTs a signed JSON event to your server when something happens in the business. Use it to sync documents to your own system, trigger fulfilment, or drive Zapier/Make ("Catch Hook").
Manage endpoints in the dashboard at /billing/settings/webhooks: add an https URL, choose the events, and copy the signing secret (shown once when created or regenerated).
Events#
| Event | Fires when |
|---|---|
document.issued | Any document is issued — from the dashboard, the API, a connector, or the mirror credit note of a cancellation. |
document.cancelled | A fiscal document is cancelled (its mirror credit is issued), or a non-fiscal document is cancelled directly. |
document.paid | A referenced document's open balance reaches 0 as a result of an issue: a receipt settling an invoice, or a credit note crediting it in full. The event carries the settled document. |
customer.created | A customer card is created in the dashboard. (POST /customers does not fire it.) |
customer.updated | A customer card is saved in the dashboard. |
proposal.approved | A customer digitally approves a quote. |
Webhooks need a plan that includes them; on other plans endpoints stay stored but nothing is dispatched.
The envelope#
Every delivery body is:
{
"id": "b6e2c1d4-8f3a-4e7b-9c21-5d0a7f6e3b18",
"event": "document.issued",
"created_at": "2026-09-29T10:00:00.000Z",
"tenant_id": "3c8f…",
"data": { }
}id is a UUID per event; retries and manual re-sends of the same event carry the same id, so de-duplicate on it. sandbox: true is added at the top level for events minted by sandbox mode (never a real document).
data for document.*#
{
"id": "3f2b7c1e-9d4a-4c55-8a51-6d1e0f6b7a10",
"docType": 1,
"docNumber": 1042,
"status": 1,
"customerId": null,
"customerName": "אורן כהן — סטודיו לעיצוב",
"totalAgorot": 450000,
"balanceAgorot": 450000,
"currency": "ILS",
"issueDate": "2026-09-29"
}Sandbox document.issued events are slimmer: id is sbx_<uuid>, docNumber is "SBX-000003", status is 0, and balanceAgorot / issueDate are absent.
data for customer.*#
{ "id": "9c1d…", "name": "נועה לוי", "email": "[email protected]", "uniqueId": "301234567", "active": true }data for proposal.approved#
{
"proposalId": "…", "documentId": "…", "docNumber": 17, "docType": 7,
"customerName": "…", "totalAgorot": 118000,
"approverName": "…", "approverIp": "…", "approvedAt": "2026-09-29T10:00:00.000Z",
"publicUrl": "https://…/q/…"
}Headers#
| Header | Value |
|---|---|
Content-Type | application/json |
X-SLK-Event | the event name |
X-SLK-Delivery-Id | id of this delivery |
X-SLK-Signature | t=<unix seconds>,v1=<hex> |
Verifying the signature#
v1 is HMAC-SHA256(secret, "<t>.<rawBody>") as lowercase hex, where rawBody is the exact bytes of the request body and secret is the endpoint's signing secret. Always:
- Read the raw body before parsing JSON (re-serialising changes the bytes).
- Recompute and compare in constant time.
- Reject when
tis too old (5 minutes is sensible) to stop replays. - Respond
2xxquickly and process asynchronously.
Node#
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: app.post("/hook", express.raw({ type: "application/json" }), (req, res) =>
// verify(req.body.toString("utf8"), req.get("X-SLK-Signature"), SECRET) ? res.sendStatus(200) : res.sendStatus(401));PHP#
function slk_verify(string $raw, string $header, string $secret, int $tolerance = 300): bool {
parse_str(str_replace(',', '&', $header), $p); // t=…&v1=…
if (empty($p['t']) || empty($p['v1']) || abs(time() - (int)$p['t']) > $tolerance) return false;
return hash_equals(hash_hmac('sha256', $p['t'] . '.' . $raw, $secret), $p['v1']);
}
$raw = file_get_contents('php://input');
if (!slk_verify($raw, $_SERVER['HTTP_X_SLK_SIGNATURE'] ?? '', getenv('SLK_WEBHOOK_SECRET'))) { http_response_code(401); exit; }Python#
import hashlib, hmac, time
def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
p = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
if "t" not in p or "v1" not in p or abs(time.time() - int(p["t"])) > tolerance:
return False
expected = hmac.new(secret.encode(), p["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, p["v1"])The SDKs ship this as verifyWebhook / verify_webhook, and the Postman collection contains a pre-request script that signs a test delivery the same way.
Delivery and retries#
- Timeout: 10 seconds per attempt. Redirects are not followed (a 3xx counts as a failure).
- Any non-2xx response, a timeout or a network error schedules a retry at 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours after the previous failure. After the last retry the delivery is marked failed; it can be re-sent by hand from the delivery log.
- An endpoint that fails 20 deliveries in a row is disabled automatically; one success resets the streak. Re-enable it in the dashboard.
- Destinations must be public https URLs: no
localhost, internal hostnames or private IP addresses, and the address is re-checked at every send. - Deliveries are sent right after the event and swept by a scheduled job; order is not guaranteed across events, so use
created_atand the document status to reconcile.
Testing#
- "שליחת אירוע בדיקה" on the webhooks screen sends a synthetic
document.issued(sandbox: true,docNumber: 0) to your endpoints immediately. - In a sandbox account, every API-issued
SBX-document firesdocument.issuedwithsandbox: true, so you can test the full path without issuing anything real. - Local development: expose your machine with a tunnel that gives a public https URL.