Webhooks
Webhooks push changes to your system: ZERPY sends a signed POST request to your URL when stock changes, an order is created, confirmed, shipped or cancelled, a product changes or an invoice is issued.
Creating a webhook
Only an owner or admin can manage webhooks.
- In ZERPY open Integrations → Webhooks → New webhook.
- Endpoint URL — a public
https://URL on your server. - Description (optional) — e.g. "Website stock sync".
- Events — choose the events you want, or leave the selection empty for All events.
- Save. The signing secret (
whsec_…) is shown once — store it on your server (e.g.ZERPY_WEBHOOK_SECRET). You need it to verify signatures.
A new webhook starts from "now": it receives changes made after it was created, not older history.
In the webhook list you can Edit, Disable / Enable, Test, create a New signing secret (the old one stops working; the new one is shown once) and delete the webhook.
URL rules
- Must be
https://. Plainhttp://is refused. - Must be a public address:
localhost, private, loopback and link-local IPs are refused — also when a public hostname resolves to a private IP at delivery time. - No user name or password in the URL.
- Redirects are not followed — a
3xxanswer counts as a failure. Use the final URL.
Events
| Event | Sent when | data |
|---|---|---|
product.created | A product is created | Product object |
product.updated | Product data, translations or images change | Product object |
stock.changed | Stock moves or reservations change (order confirmed / cancelled / reopened, production planned / reopened / cancelled, receipts, shipments, stock counts …) | { "items": [{ "sku", "available", "on_hand" }] } |
order.created | A sales order is created (any channel) | Order object |
order.confirmed | An order is confirmed (stock reserved) | Order object |
order.shipped | A shipment is sent | { "order": Order, "shipment": Shipment } |
order.cancelled | An order is cancelled | Order object |
invoice.issued | An invoice or credit note is issued | Invoice summary (below) |
ping | The Test button | { "message": "Hello from ZERPY", "webhook_id": "…" } |
The product object in webhooks is the company view in the company's own language (like GET /products without ?lang=). stock.changed lists only stock products; services never appear.
Request format
POST /zerpy-webhook HTTP/1.1
Content-Type: application/json
User-Agent: ZERPY-Webhooks/1
Zerpy-Event: stock.changed
Zerpy-Delivery: evt_1234_0
Zerpy-Signature: t=1794055247,v1=5f2c9e…
{ "id": "evt_1234_0", "type": "stock.changed", "created_at": "2026-11-07T10:00:12.345Z", "data": { … } }| Field | Description |
|---|---|
id | Delivery id, also in the Zerpy-Delivery header. Stable across retries — use it to deduplicate |
type | Event type, also in Zerpy-Event |
created_at | When this request body was built |
data | Event data |
Payload examples
stock.changed:
{
"id": "evt_1234_0",
"type": "stock.changed",
"created_at": "2026-11-07T10:00:12.345Z",
"data": {
"items": [
{ "sku": "P1", "available": "4", "on_hand": "10" },
{ "sku": "P2", "available": "0", "on_hand": "1" }
]
}
}order.shipped (shipment is the shipment that was just sent, from the order's shipments):
{
"id": "evt_1290_0",
"type": "order.shipped",
"created_at": "2026-11-08T09:12:15.000Z",
"data": {
"order": { "id": "8622…", "number": "SO-00042", "external_id": "1001", "status": "shipped", "…": "full Order object" },
"shipment": {
"number": "SH-00007",
"carrier": "omniva",
"tracking_number": "CC123EE",
"shipped_at": "2026-11-08T09:12:00.000Z",
"lines": [{ "sku": "P1", "quantity": "2" }]
}
}
}invoice.issued (order is null for invoices without an order; credit notes have type: "credit_note" and a negative total):
{
"id": "evt_1301_0",
"type": "invoice.issued",
"created_at": "2026-11-08T09:30:11.000Z",
"data": {
"number": "INV-00031",
"type": "invoice",
"total": "28.79",
"currency": "EUR",
"issue_date": "2026-11-08T00:00:00.000Z",
"due_date": "2026-11-08T00:00:00.000Z",
"order": { "id": "8622…", "number": "SO-00042", "external_id": "1001" }
}
}Current state, not history
Payloads are built when the webhook is sent, a few seconds after the change. data therefore shows the current state — if an order was confirmed and shipped quickly, the order.confirmed payload may already show status: "shipped". Treat events as "this changed, here is how it looks now".
Verifying the signature
Every request is signed with your webhook's secret:
Zerpy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>To verify:
- Read the raw request body (before JSON parsing — re-serialising changes the bytes).
- Parse
tandv1from the header. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare with
v1in constant time. - Reject requests whose
tis more than 5 minutes from your clock (replay protection).
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
const SECRET = process.env.ZERPY_WEBHOOK_SECRET
function verifyZerpy(rawBody, header) {
const m = /t=(\d+),v1=([0-9a-f]+)/.exec(header ?? '')
if (!m) return false
const [, t, v1] = m
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex')
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}
app.post('/zerpy-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8')
if (!verifyZerpy(raw, req.get('Zerpy-Signature'))) return res.sendStatus(401)
const event = JSON.parse(raw)
// deduplicate on event.id, then handle event.type / event.data
res.json({ ok: true })
})
app.listen(3000)<?php
$secret = getenv('ZERPY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_ZERPY_SIGNATURE'] ?? '';
if (!preg_match('/t=(\d+),v1=([0-9a-f]+)/', $header, $m)) {
http_response_code(401); exit;
}
[$all, $t, $v1] = $m;
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
http_response_code(401); exit;
}
$event = json_decode($raw, true);
// deduplicate on $event['id'], then handle $event['type'] / $event['data']
header('Content-Type: application/json');
echo json_encode(['ok' => true]);More languages (Python, a Nuxt server route) are in Code samples.
Delivery, retries and auto-disable
- Answer with
2xxwithin 10 seconds. Anything else — other status codes, timeouts, connection errors, redirects — is a failure. Do slow work (e.g. updating many products) after answering, in a queue. - In order, one at a time per webhook. Events are delivered in the order they happened. If one delivery fails, later events wait until it succeeds.
- Retries with exponential backoff: the wait starts at about 20 seconds and doubles after every consecutive failure, up to 1 hour between attempts.
- Auto-disable: after 30 consecutive failures (roughly a day) the webhook is disabled and its last error is shown in ZERPY. When you fix your endpoint and Enable it again, the failure counter is reset and delivery continues from the event that failed — nothing is lost.
- At-least-once: a delivery can arrive more than once (e.g. when one change produces several events and a later one failed, the earlier ones are sent again on retry). Deduplicate by
id. - Events are sent at the earliest about 10 seconds after the change (so that all related changes are committed), typically within 10–20 seconds.
Delivery log and status
The webhook list in Integrations → Webhooks shows for every webhook:
- Status: Active, Failing (with the time of the next retry) or Disabled.
- When it last delivered successfully and the last error.
- Recent deliveries — event type, HTTP status or error, and duration of the latest attempts (ZERPY keeps the last 50).
Test button
Test sends a signed ping event to the URL right away (it is sent even when ping is not among the chosen events, and it is not retried):
{
"id": "ping_1794055247123",
"type": "ping",
"created_at": "2026-11-07T10:00:00.000Z",
"data": { "message": "Hello from ZERPY", "webhook_id": "0b6f…" }
}ZERPY shows the answer ("Endpoint answered 200") or that the endpoint did not accept the test event. Use it to check your signature verification before real events arrive.