Webhooks
Zylar webhook events, how to subscribe an HTTPS endpoint, how to verify the X-Zylar-Signature HMAC-SHA256 header in Node.js and Python, and how retries work.
Webhooks send Zylar events to your own HTTPS endpoint as they happen: a reply, an interested lead, a bounce, an auto-paused domain. Zylar POSTs a signed JSON payload for each event and retries failed deliveries. This page lists the events, shows how to subscribe and explains how to verify signatures.
Events
| Event | When it fires |
|---|---|
lead.replied | A lead sent any reply. |
reply.interested | A reply was tagged Interested. |
reply.ooo | An out-of-office reply, with the return date. |
lead.bounced | A hard or soft bounce. |
lead.unsubscribed | A lead opted out. |
sequence.completed | A lead finished all steps of a sequence. |
domain.paused | Auto-pause triggered on a domain. See Monitoring and auto-pause. |
Subscribe only to the events you need.
Subscribe an endpoint
Create a subscription with POST /webhooks, passing your HTTPS URL and the events you want:
curl -X POST https://api.zylar.io/v1/webhooks \
-H "Authorization: Bearer zy_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/hooks/zylar",
"events": ["reply.interested", "domain.paused"]
}'
Response 201 Created:
{ "id": "wh_3n7", "secret": "whsec_..." }
Store the secret securely, for example in an environment variable. You use it to verify that each delivery came from Zylar. API keys are scoped to one workspace, so a subscription receives events from the workspace whose key created it. See API reference.
Payload
Each delivery is an HTTPS POST with a JSON body. An example reply.interested payload:
{
"event": "reply.interested",
"lead": {
"email": "jordan@example.com",
"first_name": "Jordan",
"company": "Example Co"
},
"sequence": "q4-agencies",
"step": 2,
"reply": "Sure, send times for Thursday",
"inbox": "sender@yourdomain.com"
}
Use the event field to route each delivery to the right handler.
Verify signatures
Every payload is signed. The X-Zylar-Signature header contains an HMAC-SHA256 of the request body, computed with your webhook secret. To verify a delivery:
- Read the raw request body, exactly as received. Don't parse and re-serialize the JSON first, or the signature won't match.
- Compute HMAC-SHA256 of the raw body using your webhook secret as the key.
- Compare your result with the
X-Zylar-Signatureheader using a constant-time comparison. - Reject the request if they don't match.
Constant-time comparison matters: a normal string comparison can leak timing information that helps an attacker forge a signature.
Node.js
import express from "express";
import crypto from "node:crypto";
const app = express();
const secret = process.env.ZYLAR_WEBHOOK_SECRET;
function isValidSignature(rawBody, signature) {
if (!signature) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the raw body for this route so the bytes match what Zylar signed.
app.post("/hooks/zylar", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("X-Zylar-Signature"))) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
// Handle the event after acknowledging, e.g. queue it for processing.
console.log(event.event);
});
app.listen(3000);
crypto.timingSafeEqual throws if the buffers differ in length, so the length check comes first.
Python
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["ZYLAR_WEBHOOK_SECRET"].encode()
def is_valid_signature(raw_body: bytes, signature: str | None) -> bool:
if not signature:
return False
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/hooks/zylar")
def zylar_webhook():
raw_body = request.get_data() # raw bytes, before any JSON parsing
if not is_valid_signature(raw_body, request.headers.get("X-Zylar-Signature")):
abort(401)
event = json.loads(raw_body)
# Handle event["event"], ideally by queueing it for async processing.
return "", 200
Retries
If a delivery fails, Zylar retries it 5 times over 24 hours. To work well with retries:
- Respond quickly. Acknowledge the delivery first and do slow work (CRM updates, enrichment, notifications) asynchronously.
- Make handlers idempotent. A retry can deliver an event you've already processed, so make processing the same event twice harmless.
- Monitor your endpoint. After the retries are exhausted, that delivery is not retried again.
No-code options
The same events work in Zapier and Make. Typical automations:
| Event | Action |
|---|---|
reply.interested | Create a contact and deal in HubSpot |
reply.interested | Post the reply with lead details to a Slack channel |
domain.paused | Alert your deliverability channel in Slack |
See Integrations.