Webhooks
Webhooks deliver real-time event notifications to your server. When an email is sent, bounced, replied to, or a lead completes their sequence, zend.sh sends an HTTP POST to your configured endpoint.
Event types
| Event | Description |
|---|---|
email.sent | Email was successfully sent via SMTP |
email.bounced | Email bounced (hard or soft bounce) |
email.replied | Recipient replied to a sent email |
email.complained | Recipient marked email as spam |
lead.completed | Lead finished all steps in the sequence |
lead.unsubscribed | Lead clicked the unsubscribe link |
Create a webhook
const webhook = await zend.webhooks.create({
url: 'https://yourapp.com/webhooks/zend',
events: ['email.sent', 'email.bounced', 'email.replied'],
})The response includes a secret field. Store this securely — you need it to verify webhook signatures.
Webhook payload
Each webhook delivery sends a JSON POST request:
{
"id": "evt_abc123",
"type": "email.replied",
"created_at": "2026-03-25T14:30:00Z",
"data": {
"campaign_id": "camp_xyz",
"lead_id": "lead_abc",
"account_id": "acct_123",
"email": "jane@acme.com"
}
}Signature verification
Every webhook request includes an x-zend-signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature to ensure the request came from zend.sh.
Node.js / TypeScript
import { createHmac, timingSafeEqual } from 'crypto'
function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
// Use timing-safe comparison to prevent timing attacks
if (signature.length !== expected.length) return false
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
// In your webhook handler:
app.post('/webhooks/zend', (req, res) => {
const signature = req.headers['x-zend-signature'] as string
const rawBody = req.body // raw string, not parsed JSON
if (!verifyWebhook(rawBody, signature, process.env.ZEND_WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(rawBody)
// Process the event...
res.status(200).send('OK')
})Python
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)Retry behavior
If your endpoint returns a non-2xx status code or times out, zend.sh retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | ~1 minute |
| 2nd retry | ~5 minutes |
| 3rd retry | ~30 minutes |
After 3 failed attempts, the delivery is marked as failed. You can inspect failed deliveries via the API:
const { deliveries } = await zend.webhooks.deliveries(webhookId)Best practices
-
Respond with 200 quickly — Process events asynchronously. Return a 200 status immediately and handle the event in a background job.
-
Verify signatures always — Never skip signature verification, even in development. Use timing-safe comparison functions.
-
Handle duplicates — In rare cases, the same event may be delivered more than once. Use the event
idfield to deduplicate. -
Use specific events — Subscribe only to the events you need rather than all events. This reduces noise and processing overhead.
-
Monitor delivery health — Check webhook delivery status periodically to catch endpoint issues early.
Manage webhooks
// List all webhooks
const { webhooks } = await zend.webhooks.list()
// Update events
await zend.webhooks.update(webhookId, {
events: ['email.replied', 'lead.completed'],
})
// Delete a webhook
await zend.webhooks.delete(webhookId)