事件与 Webhook
在发送记录中跟踪邮件状态,并在你自己的端点上接收带签名的送达、退信、投诉、打开和点击事件。
你发送的每封邮件都会获得一个邮件 ID,并在排队、投递、重试或退信的过程中依次经历一系列状态。你可以在控制台的发送记录中跟踪,用 API 查询,也可以通过 Webhook 把每个事件推送到你自己的服务器。
邮件状态
每份收件人副本都有一个状态。状态只会向前推进:例如已送达的邮件之后仍可能变为退信或投诉,但不会回到排队中。
| 状态 | 控制台显示 | 含义 |
|---|---|---|
queued | 排队中 | 已接收,等待投递 |
deferred | 延迟重试 | 收件服务器返回临时错误,会在最长 72 小时内重试投递。 |
delivered | 已送达 | 收件服务器已接收邮件 |
bounced | 退信 | 投递永久失败,可能发生在投递过程中,也可能来自之后收到的退信报告。退信类型见下文。 |
complained | 投诉 | 收件人将邮件标记为垃圾邮件 |
rejected | 已拒绝 | 发送前即被拒绝,例如收件人在抑制名单中、不在沙箱允许范围内,或内容被拒 |
failed | 失败 | 重试 72 小时后仍无法投递,或未能入队 |
打开、点击和退订不属于状态。它们会记录在邮件上(opened_at、clicked_at、unsubscribed_at),并作为 Webhook 事件发送。在发送记录中,已打开的邮件会多一个 已打开 标签。
退信类型
| 类型 | 何时发生 | 影响 |
|---|---|---|
| 硬退信 | 永久性的 5xx 拒收,例如用户不存在 | 地址加入全局抑制名单 |
| 软退信 | 邮箱已满(x.2.2),或因系统、网络、策略原因拒收(x.3.x 到 x.7.x,例如 IP 信誉拦截) | 地址不会被抑制 |
发送记录
发信流的 发送记录 标签页列出最近 30 天的邮件,包括时间、收件人、主题、分类、来源(API 或 SMTP)和状态。你可以按 收件人、状态、来源、类别 和日期范围筛选。
点击某封邮件可打开 邮件详情:状态、发件人、主题、分类、标签、收件服务器的最后一次响应,以及事件列表,其中包含每次投递尝试的 SMTP 响应码、响应内容、退信类型、MX 主机和 IP。
概览 标签页会按 7 天或 30 天汇总同样的数据:发送量、送达率、退信率、投诉率、打开和点击。
Webhook
Webhook 会在事件发生时把它发送到你的 HTTPS 端点,这样你的应用就能做出响应,例如在硬退信后把用户的地址标记为无效。
配置 Webhook
- 打开发信流,进入 Webhook 标签页。
- 在 接收地址 中填写一个公网可访问的
https://URL。不接受内网和本地地址。 - 在 事件 中选择需要的事件,默认全选。
- 点击 保存。首次保存 URL 时,会显示一次 签名密钥,以
whsec_开头。请把它保存在你的服务器上。 - 点击 发送测试,发送一个
test事件,确认你的端点能正常响应。
如需更换密钥,点击 重置签名密钥,旧密钥会立即失效。如需停止接收事件,清空 URL 并保存。
事件
| 事件 | 控制台显示 | 触发时机 |
|---|---|---|
delivered | 送达 | 收件服务器已接收邮件 |
deferred | 延迟 | 某次投递尝试临时失败,每次尝试发送一次。 |
bounced | 退信 | 永久失败。data.bounce_type 为 hard 或 soft。 |
complained | 投诉 | 收件人将邮件举报为垃圾邮件 |
failed | 失败 | 72 小时后放弃投递 |
rejected | 拒绝 | 发信服务器拒绝了某个收件人 |
opened | 打开 | 邮件首次被打开(需开启打开跟踪) |
clicked | 点击 | 每次点击被跟踪的链接(需开启点击跟踪) |
unsubscribed | 退订 | 收件人退订 |
被 HTTP API 直接拒绝的收件人会在 API 响应的 rejected 字段中返回,不会触发 Webhook。
请求格式
每个事件以 POST 请求发送,请求体为 JSON:
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Yeap-TM-Webhook/1
X-Yeap-Signature: t=1760955304,v1=5f2b9c...e81a
{
"id": "evt_01JAQ40B2C3D4E5F6G7H8J9K0M",
"type": "bounced",
"stream_id": 12,
"message_id": "01JAQ3Z9V8K4M2N6P7R5S1T0XW",
"created_at": "2026-10-20T10:15:04+00:00",
"data": {
"rcpt": "alice@example.org",
"smtp_code": 550,
"smtp_response": "5.1.1 The email account that you tried to reach does not exist",
"bounce_type": "hard",
"mx": "mx.example.org"
}
}id 对每个事件唯一,可用来忽略重复事件。data 的内容取决于事件类型:
| 事件 | data 字段 |
|---|---|
delivered、deferred、bounced、complained、failed、rejected | rcpt,以及在有值时提供的 smtp_code、smtp_response、bounce_type、mx、at |
opened | rcpt、ip、user_agent |
clicked | rcpt、url、ip、user_agent |
unsubscribed | rcpt、category、via |
test | message |
验证签名
X-Yeap-Signature 邮件头的格式为 t=<timestamp>,v1=<signature>。签名是对 <timestamp>.<raw request body> 计算的十六进制 HMAC-SHA256,密钥为完整的签名密钥(包括 whsec_)。验证请求的步骤:
- 在解析 JSON 之前,按收到的原样读取原始请求体。
- 对
t + "." + body计算 HMAC,并用恒定时间比较与v1对比。 - 拒绝时间戳超过 5 分钟的请求,以防重放攻击。
Python:
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts.get("t", ""), parts.get("v1", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Node.js:
const crypto = require('crypto');
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}响应与重试
- 请在 10 秒内返回任意
2xx状态码,其他情况均视为失败。 - 投递失败会以递增的间隔重试,从 1 分钟开始,最长间隔 4 小时。事件发生 24 小时后停止重试。
- 事件可能重复送达,也可能乱序到达。请用事件
id去重,并以邮件状态判断当前情况。
Webhook 标签页中的 最近投递 表格显示最近 100 次投递,包括事件、状态(待投递、成功、重试中、已放弃)、尝试次数、最后一次错误和下次尝试时间。