Webhook Configuration
Webhooks allow you to receive real-time email status updates, including delivery, bounce, complaint, and other events.
What is a Webhook
When an email status changes, Zeabur Email sends an HTTP POST request to your configured URL containing event details.
Creating a Webhook
Log in to Console
Visit the Zeabur Email management page in the Zeabur console.
Create Webhook
- Go to “Webhook Management”
- Click “Create Webhook”
- Enter Webhook URL (e.g.,
https://yourapp.com/webhooks/zsend) - Select event types to receive
- Click “Save”
Get Signature Secret
After creation, the system generates a signature secret for verifying request origin. Save it securely - it’s only shown once!
Verify Webhook
Click the “Verify” button, and the system will send a test request to your URL to ensure it can receive properly.
Event Types
Zeabur Email supports the following event types:
| Event Type | Description |
|---|---|
send | Email sent to mail server |
delivery | Email successfully delivered to recipient |
bounce | Email bounced (hard or soft bounce) |
complaint | Recipient marked as spam |
reject | Email rejected for sending (unverified domain, content violation, etc.) |
Webhook Request Format
Request Headers
POST /your-webhook-endpoint HTTP/1.1
Host: yourapp.com
Content-Type: application/json
X-ZSend-Event: delivery
X-ZSend-Timestamp: 1768812348
X-ZSend-Signature: sha256=dc36b914ecdb7bb6f4ba8714b6ccb04d46e85af5cd1bc52744e0208964f5fb34
User-Agent: ZSend-Webhook/1.0| Header | Description |
|---|---|
X-ZSend-Event | Event type |
X-ZSend-Timestamp | Unix timestamp |
X-ZSend-Signature | HMAC-SHA256 signature |
Request Body
Delivery Event (delivery)
{
"event": "delivery",
"timestamp": "2026-01-19T08:45:48Z",
"email": {
"id": "696def36de644b22ae711500",
"message_id": "0111019bd56e71c1-8ccdb66d-5d71-433f-9a9a-0766822f8955-000000",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome to Zeabur Email",
"sent_at": "2026-01-19T08:45:42Z"
},
"data": {
"processing_time_millis": 5116,
"recipients": ["[email protected]"],
"smtp_response": "250 OK"
}
}Bounce Event (bounce)
{
"event": "bounce",
"timestamp": "2026-01-19T08:45:50Z",
"email": {
"id": "696def36de644b22ae711501",
"message_id": "0111019bd56e71c1-8ccdb66d-5d71-433f-9a9a-0766822f8955-000001",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Test Email"
},
"data": {
"bounce_type": "Permanent"
"bounce_subtype": "General",
"bounced_recipients": [
{
"email_address": "[email protected]",
"status": "5.1.1",
"diagnostic_code": "smtp; 550 5.1.1 user unknown"
}
]
}
}Complaint Event (complaint)
{
"event": "complaint",
"timestamp": "2026-01-19T09:00:00Z",
"email": {
"id": "696def36de644b22ae711502",
"message_id": "0111019bd56e71c1-8ccdb66d-5d71-433f-9a9a-0766822f8955-000002",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Weekly Newsletter"
},
"data": {
"complaint_feedback_type": "abuse",
"complained_recipients": ["[email protected]"]
}
}Signature Verification
To ensure requests come from Zeabur Email, you need to verify the X-ZSend-Signature.
Verification Steps
- Get
X-ZSend-TimestampandX-ZSend-Signaturefrom request headers - Read the raw request body
- Construct signature message:
{timestamp}.{body} - Calculate signature using HMAC-SHA256 and your Secret
- Compare the result with
X-ZSend-Signature(format:sha256=xxx)
Node.js Example
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body to preserve original request body (must be before express.json())
app.post('/webhooks/zsend',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.ZSEND_WEBHOOK_SECRET;
const signature = req.headers['x-zsend-signature'];
const timestamp = req.headers['x-zsend-timestamp'];
// Check required headers
if (!signature || !timestamp) {
return res.status(400).json({ error: 'Missing signature or timestamp' });
}
const body = req.body.toString('utf8'); // Raw request body
// Construct signature message
const message = `${timestamp}.${body}`;
// Calculate HMAC-SHA256
const hmac = crypto.createHmac('sha256', secret);
hmac.update(message);
const expectedSignature = 'sha256=' + hmac.digest('hex');
// Timing-safe comparison (check length first to avoid RangeError)
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expectedSignature);
const isValid = sigBuf.length === expBuf.length &&
crypto.timingSafeEqual(sigBuf, expBuf);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse JSON and process event
const payload = JSON.parse(body);
const { event, email, data } = payload;
console.log(`Received ${event} event for email ${email.id}`);
// Return 200 to indicate successful receipt
res.status(200).json({ received: true });
}
);Python Example
import hmac
import hashlib
def verify_webhook_signature(request, secret):
signature = request.headers.get('X-ZSend-Signature')
timestamp = request.headers.get('X-ZSend-Timestamp')
body = request.get_data(as_text=True)
# Construct signature message
message = f"{timestamp}.{body}"
# Calculate HMAC-SHA256
expected_signature = 'sha256=' + hmac.new(
secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected_signature)
# Flask example
@app.route('/webhooks/zsend', methods=['POST'])
def handle_webhook():
secret = os.environ.get('ZSEND_WEBHOOK_SECRET')
if not verify_webhook_signature(request, secret):
return jsonify({'error': 'Invalid signature'}), 401
data = request.get_json()
event_type = data['event']
email_id = data['email']['id']
print(f"Received {event_type} event for email {email_id}")
return jsonify({'received': True}), 200Go Example
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
// WebhookPayload represents the webhook payload structure
type WebhookPayload struct {
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Email struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
SentAt string `json:"sent_at"`
} `json:"email"`
Data interface{} `json:"data"`
}
func verifyWebhookSignature(body []byte, signature, timestamp, secret string) bool {
// Construct signature message
message := fmt.Sprintf("%s.%s", timestamp, body)
// Calculate HMAC-SHA256
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
expectedSignature := "sha256=" + hex.EncodeToString(h.Sum(nil))
// Timing-safe comparison
return subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) == 1
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
secret := os.Getenv("ZSEND_WEBHOOK_SECRET")
// Read raw request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Get request headers
signature := r.Header.Get("X-ZSend-Signature")
timestamp := r.Header.Get("X-ZSend-Timestamp")
// Verify signature
if !verifyWebhookSignature(body, signature, timestamp, secret) {
http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
return
}
// Parse JSON
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Process event
fmt.Printf("Received %s event for email %s\n", payload.Event, payload.Email.ID)
// Return 200 to indicate successful receipt
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/zsend", handleWebhook)
http.ListenAndServe(":3000", nil)
}Retry Mechanism
If your Webhook endpoint:
- Returns a non-2xx status code
- Times out (10 seconds)
- Has network errors
Zeabur Email will automatically retry up to 3 times using exponential backoff:
- 1st retry: after 1 second
- 2nd retry: after 2 seconds
- 3rd retry: after 4 seconds
After 3 failed retries, the Webhook event will be discarded. Ensure your endpoint can respond quickly.
Best Practices
1. Quick Response
Webhook handlers should quickly return a 200 response, then process events asynchronously:
app.post('/webhooks/zsend',
express.raw({ type: 'application/json' }),
async (req, res) => {
const secret = process.env.ZSEND_WEBHOOK_SECRET;
const signature = req.headers['x-zsend-signature'];
const timestamp = req.headers['x-zsend-timestamp'];
if (!signature || !timestamp) {
return res.status(400).json({ error: 'Missing signature or timestamp' });
}
const body = req.body.toString('utf8');
// Verify signature first
const message = `${timestamp}.${body}`;
const hmac = crypto.createHmac('sha256', secret);
hmac.update(message);
const expectedSignature = 'sha256=' + hmac.digest('hex');
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Return 200 immediately
res.status(200).json({ received: true });
// Process event asynchronously
const payload = JSON.parse(body);
setImmediate(async () => {
try {
await processWebhookEvent(payload);
} catch (error) {
console.error('Failed to process webhook:', error);
}
});
}
);2. Idempotent Processing
The same event may be sent multiple times, so your processing logic should be idempotent:
async function processWebhookEvent(event) {
const { email, event: eventType } = event;
// Check if event has been processed
const existing = await db.webhookEvents.findOne({
email_id: email.id,
event_type: eventType,
timestamp: event.timestamp
});
if (existing) {
console.log('Event already processed, skipping');
return;
}
// Process event
await updateEmailStatus(email.id, eventType);
// Record as processed
await db.webhookEvents.create({
email_id: email.id,
event_type: eventType,
timestamp: event.timestamp
});
}3. Monitoring and Alerting
Set up monitoring to track Webhook health:
const webhookMetrics = {
received: 0,
verified: 0,
processed: 0,
failed: 0
};
app.post('/webhooks/zsend',
express.raw({ type: 'application/json' }),
async (req, res) => {
webhookMetrics.received++;
const secret = process.env.ZSEND_WEBHOOK_SECRET;
const signature = req.headers['x-zsend-signature'];
const timestamp = req.headers['x-zsend-timestamp'];
if (!signature || !timestamp) {
webhookMetrics.failed++;
return res.status(400).json({ error: 'Missing signature or timestamp' });
}
const body = req.body.toString('utf8');
// Verify signature
const message = `${timestamp}.${body}`;
const hmac = crypto.createHmac('sha256', secret);
hmac.update(message);
const expectedSignature = 'sha256=' + hmac.digest('hex');
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
webhookMetrics.failed++;
return res.status(401).json({ error: 'Invalid signature' });
}
webhookMetrics.verified++;
res.status(200).json({ received: true });
const payload = JSON.parse(body);
try {
await processWebhookEvent(payload);
webhookMetrics.processed++;
} catch (error) {
webhookMetrics.failed++;
// Send alert
await sendAlert('Webhook processing failed', error);
}
}
);4. Test Webhook
Before deploying to production, use testing tools to verify the Webhook:
# Use ngrok to expose local server
ngrok http 3000
# Configure ngrok URL in Zeabur Email console
# https://abc123.ngrok.io/webhooks/zsend
# Send test emails and observe Webhook eventsIt’s recommended to view Webhook logs in the Zeabur Email console to understand sending status and response details.