Webhooks
Zahrex calls your endpoint when something happens. Register endpoints on your app page in Zahrex.
Endpoints must use HTTPS.
Each app can have up to 5 endpoints.
Events we send
lead.created
A new lead was created for your project.
Example payload
{
"lead_id": "lead_example1",
"name": "Sara Example",
"phone": "+212600000000",
"email": "sara@example.com",
"stage": "signed_up",
"subscription": null,
"project_id": "prj_example1"
}lead.stage_changed
A lead moved to another stage.
Example payload
{
"lead_id": "lead_example1",
"name": "Sara Example",
"phone": "+212600000000",
"email": "sara@example.com",
"stage": "trial",
"subscription": null,
"project_id": "prj_example1",
"from": "signed_up",
"to": "trial"
}subscription.started
A lead's subscription started.
Example payload
{
"lead_id": "lead_example1",
"name": "Sara Example",
"phone": "+212600000000",
"email": "sara@example.com",
"stage": "paying",
"subscription": {
"external_id": "sub_2001",
"plan": "Pro",
"status": "active",
"amount": 29,
"currency": "USD",
"interval": "month",
"started_at": "2026-11-01T09:00:00+00:00",
"renewed_at": null,
"canceled_at": null
},
"project_id": "prj_example1"
}subscription.renewed
A lead's subscription renewed.
Example payload
{
"lead_id": "lead_example1",
"name": "Sara Example",
"phone": "+212600000000",
"email": "sara@example.com",
"stage": "paying",
"subscription": {
"external_id": "sub_2001",
"plan": "Pro",
"status": "active",
"amount": 29,
"currency": "USD",
"interval": "month",
"started_at": "2026-11-01T09:00:00+00:00",
"renewed_at": "2026-12-01T09:00:00+00:00",
"canceled_at": null
},
"project_id": "prj_example1"
}subscription.canceled
A lead's subscription was canceled.
Example payload
{
"lead_id": "lead_example1",
"name": "Sara Example",
"phone": "+212600000000",
"email": "sara@example.com",
"stage": "churned",
"subscription": {
"external_id": "sub_2001",
"plan": "Pro",
"status": "canceled",
"amount": 29,
"currency": "USD",
"interval": "month",
"started_at": "2026-11-01T09:00:00+00:00",
"renewed_at": null,
"canceled_at": "2026-12-15T09:00:00+00:00"
},
"project_id": "prj_example1"
}Verify the signature
Every delivery is signed with HMAC-SHA256 using your endpoint secret. The signature is in the Zahrex-Signature header as t=<unix>,v1=<hex>. The signed payload is <t>.<raw body>. Other headers: Zahrex-Event-Id, Zahrex-Delivery-Id.
Always verify against the raw request body, compare signatures in constant time, and reject old timestamps to stop replays.
Node.js
import crypto from "node:crypto"
// Keep the signing secret on your server (env var), never in a repo or browser.
const SECRET = process.env.ZAHREX_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300 // reject old timestamps (replay protection)
// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyZahrexWebhook(rawBody, headers) {
const header = headers["zahrex-signature"] ?? ""
// Header looks like: t=<unix timestamp>,v1=<hex signature>
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)))
const t = Number(parts.t)
const v1 = parts.v1
if (!Number.isFinite(t) || !v1) return false
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`)
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(v1)
// Constant-time comparison; lengths must match first or timingSafeEqual throws.
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Python
import hashlib
import hmac
import os
import time
# Keep the signing secret on your server (env var), never in a repo or browser.
SECRET = os.environ["ZAHREX_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300 # reject old timestamps (replay protection)
def verify_zahrex_webhook(raw_body: bytes, headers: dict) -> bool:
# raw_body must be the exact bytes received, before any JSON parsing.
# Header names can arrive in any case, depending on your framework.
header = {k.lower(): v for k, v in headers.items()}.get("zahrex-signature", "")
# Header looks like: t=<unix timestamp>,v1=<hex signature>
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
# Constant-time comparison.
return hmac.compare_digest(expected, v1)Responses, retries and auto-disable
- Respond with a 2xx within 10 seconds.
- Failed deliveries are retried after these delays (seconds): 60, 300, 1800, 7200, 21600, 43200.
- After 20 consecutive failures the endpoint is disabled automatically. Re-enable it from the app page once fixed.
- Treat deliveries as at-least-once: use the event
idto ignore duplicates.