Guides

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

EventDescription
email.sentEmail was successfully sent via SMTP
email.bouncedEmail bounced (hard or soft bounce)
email.repliedRecipient replied to a sent email
email.complainedRecipient marked email as spam
lead.completedLead finished all steps in the sequence
lead.unsubscribedLead 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:

AttemptDelay
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

  1. Respond with 200 quickly — Process events asynchronously. Return a 200 status immediately and handle the event in a background job.

  2. Verify signatures always — Never skip signature verification, even in development. Use timing-safe comparison functions.

  3. Handle duplicates — In rare cases, the same event may be delivered more than once. Use the event id field to deduplicate.

  4. Use specific events — Subscribe only to the events you need rather than all events. This reduces noise and processing overhead.

  5. 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)