Webhooks
Be told when something happens, instead of asking every five minutes.
Add an endpoint under Settings → Developers — or with the API — choose the events you want, and we POST JSON to it. HTTPS only, and the URL must resolve to a public address. Five endpoints per account.
Events#
| Event | Fires when |
|---|---|
lead.created | A new lead arrived — from a form, Facebook, or created by hand. |
lead.status_changed | A lead or offer moved to a different status. |
offer.sent | An offer was e-mailed to the customer. |
offer.signed | The customer signed the offer. |
offer.rejected | The customer declined the offer. |
offer.measurement_accepted | The customer accepted the price and asked to be measured. |
What a delivery looks like#
POST https://your-app.example.com/hooks/zinevuX-Zinevu-Event: offer.signedX-Zinevu-Delivery: 9f1c3e2a-... # stable across retriesX-Zinevu-Timestamp: 1788523200X-Zinevu-Signature: sha256=<hex>{ "id": "9f1c3e2a-...", "event": "offer.signed", "created_at": "2026-09-04T13:20:00+02:00", "data": { "id": 14868, "status": "signed", ... }}data is the same shape the REST endpoint returns for that record, so a handler can pass it to the code that already reads offers.
Verify every delivery#
The signature is an HMAC-SHA256 over timestamp + "." + rawBody using your endpoint's signing secret. Compare it in constant time, and reject anything whose timestamp is more than a few minutes old — that is what stops a captured request being replayed at you later.
Re-serialising the parsed JSON changes the bytes — key order, spacing, unicode escapes — and the signature will not match. Capture the raw body before your framework parses it.
const crypto = require("crypto")function verify(req, secret) { const timestamp = req.headers["x-zinevu-timestamp"] const signature = req.headers["x-zinevu-signature"] // Sign the RAW body. Re-serialising the parsed JSON changes the bytes. const expected = "sha256=" + crypto.createHmac("sha256", secret) .update(timestamp + "." + req.rawBody) .digest("hex") if (signature.length !== expected.length) return false if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return false return Math.abs(Date.now() / 1000 - Number(timestamp)) < 300}Retries and duplicates#
Delivery is at-least-once. Respond 2xx within five seconds — do the work afterwards, not before you answer — and deduplicate on X-Zinevu-Delivery, which stays the same across retries.
- Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
- An endpoint that fails 20 times in a row is switched off automatically, and the portal shows you why.
- The last 50 deliveries are kept per endpoint, with their response, and any of them can be replayed from the portal.
Testing one#
Point an endpoint at a request-bin style URL first and look at a real delivery before you write the handler. Then switch it to your own URL and make /leads the reconciliation path: if a webhook is ever missed, a nightly updated_since sweep of https://api.zinevu.com/api/public/v1/leads catches it.