Zeabur EmailWebhook Configuration

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

  1. Go to “Webhook Management”
  2. Click “Create Webhook”
  3. Enter Webhook URL (e.g., https://yourapp.com/webhooks/zsend)
  4. Select event types to receive
  5. 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 TypeDescription
sendEmail sent to mail server
deliveryEmail successfully delivered to recipient
bounceEmail bounced (hard or soft bounce)
complaintRecipient marked as spam
rejectEmail 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
HeaderDescription
X-ZSend-EventEvent type
X-ZSend-TimestampUnix timestamp
X-ZSend-SignatureHMAC-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": "hello@yourdomain.com",
    "to": ["user@example.com"],
    "subject": "Welcome to Zeabur Email",
    "sent_at": "2026-01-19T08:45:42Z"
  },
  "data": {
    "processing_time_millis": 5116,
    "recipients": ["user@example.com"],
    "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": "hello@yourdomain.com",
    "to": ["invalid@example.com"],
    "subject": "Test Email"
  },
  "data": {
    "bounce_type": "Permanent"
    "bounce_subtype": "General",
    "bounced_recipients": [
      {
        "email_address": "invalid@example.com",
        "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": "newsletter@yourdomain.com",
    "to": ["user@example.com"],
    "subject": "Weekly Newsletter"
  },
  "data": {
    "complaint_feedback_type": "abuse",
    "complained_recipients": ["user@example.com"]
  }
}

Signature Verification

To ensure requests come from Zeabur Email, you need to verify the X-ZSend-Signature.

Verification Steps

  1. Get X-ZSend-Timestamp and X-ZSend-Signature from request headers
  2. Read the raw request body
  3. Construct signature message: {timestamp}.{body}
  4. Calculate signature using HMAC-SHA256 and your Secret
  5. 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}), 200

Go 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 events

It’s recommended to view Webhook logs in the Zeabur Email console to understand sending status and response details.