返回首页

SMTPDog 开发者接入文档

清晰透明的接入规范:域名验证、SMTP 与 Email API、API Key、Webhook 验签与常见问题排查。

06 · webhooks

Webhook

接收邮件状态事件,验证签名并以幂等方式处理重试。

1. 创建接收端点

用会话 Token 管理 /webhooks,创建仅传 url。建议使用公网 HTTPS,私网与回环地址不可用,不跟随重定向。PATCH 可改 url 或 active/disabled,当前没有事件选择字段。secret 在创建和读取时都可能返回,须安全保存;它不是 API Key。

curl -X POST "${SMTPDOG_API_ORIGIN}/webhooks" \
  -H "Authorization: Bearer ${SMTPDOG_SESSION_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com/hooks/email"}'

curl -X PATCH "${SMTPDOG_API_ORIGIN}/webhooks/${SMTPDOG_WEBHOOK_ID}" \
  -H "Authorization: Bearer ${SMTPDOG_SESSION_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"status":"disabled"}'

curl -X GET "${SMTPDOG_API_ORIGIN}/webhooks/${SMTPDOG_WEBHOOK_ID}/deliveries" \
  -H "Authorization: Bearer ${SMTPDOG_SESSION_TOKEN}"

2. 四类状态事件

当前发送 sent、bounced、deferred、failed 事件。没有 opened、clicked 或 delivered 事件。payload 的 timestamp 为入队时间,不同于签名头时间。event_id 在同投递重试中不变,但不同端点或重复业务事件可能有不同 ID;事件不保证严格顺序或必达。

字段 / 配置类型 / 状态说明
event_id / message_idUUID string投递事件 ID 与邮件 ID,不是相同含义
event_type / statusstringsent / bounced / deferred / failed
from / tostring单个发件人和收件人
timestampRFC3339 string事件入队时间,与签名 Unix 秒数不同
smtp_responsestring可省略的 SMTP 上下文
{
  "event_id": "00000000-0000-4000-8000-000000000001",
  "event_type": "sent",
  "message_id": "00000000-0000-4000-8000-000000000000",
  "from": "sender@example.com",
  "to": "recipient@example.com",
  "status": "sent",
  "timestamp": "2026-01-01T00:00:00Z"
}

3. 用原始正文验签

X-Webhook-Signature 格式为 t=<unix秒>,v1=<hex>。用完整 secret 字节计算 HMAC-SHA256(t + "." + 原始正文),不能解析并重新序列化 JSON,也不能去掉 secret 前缀。使用常量时间比较。示例要求接收方配置时间容差,不假定平台固定窗口。

package webhookexample

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "strings"
    "time"
)

// raw must be the original body bytes, before JSON parsing.
// tolerance is configured and reviewed by the receiver.
func Verify(secret string, raw []byte, header string,
    now time.Time, tolerance time.Duration) bool {
    if tolerance <= 0 { return false }
    parts := strings.Split(header, ",")
    if len(parts) != 2 { return false }
    fields := map[string]string{}
    for _, part := range parts {
        pair := strings.SplitN(strings.TrimSpace(part), "=", 2)
        if len(pair) != 2 { return false }
        if _, exists := fields[pair[0]]; exists { return false }
        fields[pair[0]] = pair[1]
    }
    ts, err := strconv.ParseInt(fields["t"], 10, 64)
    if err != nil || ts <= 0 || strconv.FormatInt(ts, 10) != fields["t"] {
        return false
    }
    delta := now.Sub(time.Unix(ts, 0))
    if delta > tolerance || delta < -tolerance { return false }
    sig, err := hex.DecodeString(fields["v1"])
    if err != nil || len(sig) != sha256.Size { return false }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(fields["t"] + "."))
    mac.Write(raw)
    return hmac.Equal(mac.Sum(nil), sig)
}

// Read the raw request body and verify before parsing JSON.
// Then durably deduplicate event_id and apply business idempotency.
// Return 2xx only when safe; this function is not a full receiver.

4. 去重、确认与记录

验签后先完成持久化去重和业务幂等处理,再安全返回 2xx。非 2xx 或网络失败进入重试,当前最多初次加五次重试,间隔受服务调度影响。GET /webhooks/{id}/deliveries 返回最近 50 条,不含完整 payload 或 event_id。暂停不等于清空积压;删除会移除投递历史。没有测试、重送或 secret 轮换接口。