Zeabur EmailWebhook 設定

Webhook 設定

Webhook 允許您即時接收郵件狀態更新,包括送達、退信、投訴等事件。

什麼是 Webhook

當郵件狀態發生變化時,Zeabur Email 會向您設定的 URL 發送 HTTP POST 請求,包含事件詳情。

建立 Webhook

登入控制台

造訪 Zeabur 控制台的 Zeabur Email 管理頁面。

建立 Webhook

  1. 進入「Webhook 管理」
  2. 點擊「建立 Webhook」
  3. 輸入 Webhook URL(如 https://yourapp.com/webhooks/zsend
  4. 選擇要接收的事件類型
  5. 點擊「儲存」

取得簽章金鑰

建立後,系統會產生一個簽章金鑰(Secret),用於驗證請求來源。請妥善儲存,金鑰僅顯示一次!

驗證 Webhook

點擊「驗證」按鈕,系統會向您的 URL 發送測試請求,確保可以正常接收。

事件類型

Zeabur Email 支援以下事件類型:

事件類型說明
send郵件已發送到郵件伺服器
delivery郵件已成功送達收件人
bounce郵件被退回(硬退信或軟退信)
complaint收件人標記為垃圾郵件
reject郵件被拒絕發送(網域未驗證、內容違規等)

Webhook 請求格式

請求標頭

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
標頭說明
X-ZSend-Event事件類型
X-ZSend-TimestampUnix 時間戳記
X-ZSend-SignatureHMAC-SHA256 簽章

請求主體

送達事件 (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": "歡迎使用 Zeabur Email",
    "sent_at": "2026-01-19T08:45:42Z"
  },
  "data": {
    "processing_time_millis": 5116,
    "recipients": ["[email protected]"],
    "smtp_response": "250 OK"
  }
}

退信事件 (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": "測試郵件"
  },
  "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",
  "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": "每週通訊"
  },
  "data": {
    "complaint_feedback_type": "abuse",
    "complained_recipients": ["[email protected]"]
  }
}

簽章驗證

為了確保請求來自 Zeabur Email,您需要驗證 X-ZSend-Signature 簽章。

驗證步驟

  1. 取得請求標頭中的 X-ZSend-TimestampX-ZSend-Signature
  2. 讀取原始請求主體
  3. 構造簽章訊息:{timestamp}.{body}
  4. 使用 HMAC-SHA256 和您的 Secret 計算簽章
  5. 比較計算結果與 X-ZSend-Signature(格式為 sha256=xxx

Node.js 範例

const crypto = require('crypto');
const express = require('express');
 
const app = express();
 
// 使用 raw body 儲存原始請求主體(必須在 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'];
 
    // 檢查必要的標頭
    if (!signature || !timestamp) {
      return res.status(400).json({ error: 'Missing signature or timestamp' });
    }
 
    const body = req.body.toString('utf8'); // 原始請求主體
 
    // 構造簽章訊息
    const message = `${timestamp}.${body}`;
 
    // 計算 HMAC-SHA256
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(message);
    const expectedSignature = 'sha256=' + hmac.digest('hex');
 
    // 時間安全比較(先檢查長度避免 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' });
    }
    
    // 解析 JSON 並處理事件
    const payload = JSON.parse(body);
    const { event, email, data } = payload;
    
    console.log(`Received ${event} event for email ${email.id}`);
    
    // 回傳 200 表示成功接收
    res.status(200).json({ received: true });
  }
);

Python 範例

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)
    
    # 構造簽章訊息
    message = f"{timestamp}.{body}"
    
    # 計算 HMAC-SHA256
    expected_signature = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        message.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    # 時間安全比較
    return hmac.compare_digest(signature, expected_signature)
 
# Flask 範例
@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 範例

package main
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)
 
// WebhookPayload 代表 webhook 載荷結構
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 {
	// 構造簽章訊息
	message := fmt.Sprintf("%s.%s", timestamp, body)
	
	// 計算 HMAC-SHA256
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(message))
	expectedSignature := "sha256=" + hex.EncodeToString(h.Sum(nil))
	
	// 時間安全比較
	return subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSignature)) == 1
}
 
func handleWebhook(w http.ResponseWriter, r *http.Request) {
	secret := os.Getenv("ZSEND_WEBHOOK_SECRET")
	
	// 讀取原始請求主體
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()
	
	// 取得請求標頭
	signature := r.Header.Get("X-ZSend-Signature")
	timestamp := r.Header.Get("X-ZSend-Timestamp")
	
	// 驗證簽章
	if !verifyWebhookSignature(body, signature, timestamp, secret) {
		http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
		return
	}
	
	// 解析 JSON
	var payload WebhookPayload
	if err := json.Unmarshal(body, &payload); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	
	// 處理事件
	fmt.Printf("Received %s event for email %s\n", payload.Event, payload.Email.ID)
	
	// 回傳 200 表示成功接收
	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)
}

重試機制

如果您的 Webhook 端點:

  • 回傳非 2xx 狀態碼
  • 請求逾時(10 秒)
  • 網路錯誤

Zeabur Email 會自動重試最多 3 次,使用指數退避策略:

  • 第 1 次重試:1 秒後
  • 第 2 次重試:2 秒後
  • 第 3 次重試:4 秒後
⚠️

超過 3 次重試失敗後,該 Webhook 事件將被丟棄。請確保您的端點能夠快速回應。

最佳實踐

1. 快速回應

Webhook 處理程式應該快速回傳 200 回應,然後非同步處理事件:

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');
 
    // 先驗證簽章
    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' });
    }
 
    // 立即回傳 200
    res.status(200).json({ received: true });
 
    // 非同步處理事件
    const payload = JSON.parse(body);
    setImmediate(async () => {
      try {
        await processWebhookEvent(payload);
      } catch (error) {
        console.error('Failed to process webhook:', error);
      }
    });
  }
);

2. 冪等性處理

同一個事件可能會被發送多次,您的處理邏輯應該是冪等的:

async function processWebhookEvent(event) {
  const { email, event: eventType } = event;
  
  // 檢查事件是否已處理
  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;
  }
  
  // 處理事件
  await updateEmailStatus(email.id, eventType);
  
  // 記錄已處理
  await db.webhookEvents.create({
    email_id: email.id,
    event_type: eventType,
    timestamp: event.timestamp
  });
}

3. 監控和告警

設定監控來追蹤 Webhook 的健康狀態:

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');
 
    // 驗證簽章
    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++;
      // 發送告警
      await sendAlert('Webhook processing failed', error);
    }
  }
);

4. 測試 Webhook

在生產環境部署前,使用測試工具驗證 Webhook:

# 使用 ngrok 暴露本機伺服器
ngrok http 3000
 
# 在 Zeabur Email 控制台設定 ngrok URL
# https://abc123.ngrok.io/webhooks/zsend
 
# 發送測試郵件並觀察 Webhook 事件

建議在 Zeabur Email 控制台查看 Webhook 日誌,了解發送狀態和回應詳情。