webhook 是一次不由你发起的 API 调用。方向一反转,就多出三件活:证明是谁发来的、让重复投递不出乱子、以及在你真正开始干活之前先把它确认掉。这三件做对了,webhook 就变成无聊的基础设施,而无聊正是你要的。
🎙️ 发布并录制于: ·
用普通 API 时,是你的代码在想要答案的时候去问。用 webhook 时,是你公布一个地址,别人的系统在事件发生时来调它。轮询是每分钟问一遍「付款状态变了吗」。webhook 是说「变了就通知我」。要及时得到通知就用 webhook,需要当前的权威记录时再去调对方的 API。
# API: your app initiates
GET https://api.example.com/payments/pay_42
# webhook: provider initiates
POST https://your-app.com/webhooks/payments
Content-Type: application/json
{"id":"evt_91","type":"payment.succeeded","data":{"payment_id":"pay_42"}}把 webhook 当成通知,不要当成一道有魔力的命令。如果后续动作既贵又敏感,那就在验证完事件之后,再从对方 API 把对象取回来。这样你不会被过期字段坑到,权威记录也始终留在对方那边。
给每个提供方一条自己的路由,只接受 POST,生产环境强制 HTTPS,并且刻意把处理函数写得很小。读请求头、保留原始字节、验证消息、记录事件、把活儿丢进队列,然后返回。别把五个提供方塞进同一条「聪明」的通用路由。他们的 signature、retry 规则和载荷版本一定会分叉。
POST /webhooks/acme
# Keep the envelope. It is your audit trail and deduplication key.
{
"id": "evt_91",
"type": "invoice.paid",
"created_at": "2026-07-24T12:30:00Z",
"data": { "invoice_id": "inv_7" }
}如果对方支持锁定 webhook API 版本,就把版本钉住。存下事件类型、提供方的事件 ID、接收时间、signature 校验结果和处理状态。只活在应用日志里的载荷,之后没办法干净地重放。
公开端点谁都能调。一个「没人知道的 URL」不是认证,URL 会漏进日志和截图。像样的提供方会用共享密钥对请求的确切字节签名。你要自己重算这个 signature,用恒定时间比较,并拒绝太旧的时间戳,这样一次被抓走的请求就不能永远重放。
import { createHmac, timingSafeEqual } from "node:crypto";
function validSignature(rawBody, timestamp, suppliedHex, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(String(timestamp)).update(".").update(rawBody).digest();
const supplied = Buffer.from(suppliedHex, "hex");
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}用对方文档里写明的签名串,以及他们仍在维护的官方库。有的只签 body,有的签的是时间戳、一个点、再加 body。「HMAC SHA-256」这句话根本没告诉你字节格式。轮换密钥时留一小段重叠期,让新旧两把密钥都能通过校验。
signature 校验作用在字节上,而不是 JSON 解析器造出来的那个 JavaScript 对象上。解析再序列化一遍,可能改掉空白、转义写法或者键的格式。在人眼里数据一模一样,但 signature 已经对不上了。先拿到 raw body,验证它,只有验证通过之后才解析 JSON。
Webhook signature verification failed.
Error: No signatures found matching the expected signature for payload.
# Express: raw middleware must run before express.json()
app.post("/webhooks/acme", express.raw({ type: "application/json" }), handler);
# Put app.use(express.json()) after the webhook route.一:打印 body 的类型,确认它是 Buffer 或字节数组,不是对象。二:把 raw body 中间件挪到所有可能碰到这条路由的 JSON 解析器之前。三:用这个端点当前的密钥,对那些没被动过的字节算 signature。四:确认时间戳和 signature 的请求头名字与对方文档一致。永远不要靠跳过校验来「修好」这件事。
webhook 投递通常是至少一次。如果对方没收到你的响应,它无法知道你到底处理没处理,于是会把同一个事件再发一遍。重复是常态,不是边缘情况。给对方的事件 ID 加唯一约束,并且让由此触发的业务操作本身也满足 idempotency。另外要假设事件可能乱序到达。
CREATE TABLE webhook_events (
provider text NOT NULL, event_id text NOT NULL,
event_type text NOT NULL, payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);
INSERT INTO webhook_events(provider,event_id,event_type,payload)
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING;如果 PostgreSQL 报 duplicate key value violates unique constraint "webhook_events_pkey",说明约束干了它该干的事,是你的插入路径没做对。一:把插入改成 ON CONFLICT DO NOTHING。二:检查这一行到底有没有插进去。三:只有确实是新行时才入队。至于顺序,比较对方对象的版本号或事件时间戳,忽略过期的状态变更;绝不要用到达时间去推断先后。
同步路径该做的事是:验证,把事件和待办的活儿原子地记下来,然后回一个 2xx。之后再由 worker 去发邮件、更新搜索、调慢接口、跑业务逻辑。「先把业务处理完,再返回 200」是个糟糕的默认做法。它把对方的投递和你自己每一个依赖绑在一起,还会在系统只是有点慢的时候制造出一堆 retry。
async function webhook(req, res) {
const event = verifyAndParse(req.rawBody, req.headers);
// One database transaction writes both the inbox event and
// an outbox job. A dispatcher publishes unsent outbox rows.
await db.transaction(tx => tx.storeEventAndOutboxIfNew(event));
res.status(204).end();
}对方后台可能显示 504 Gateway Timeout,而你的日志显示这活儿几秒之后就干完了。一:给处理函数计时,找出验证之后发生的那些调用。二:把每个慢调用挪进 worker。三:在同一个事务里提交 inbox 行和 outbox 任务,或者用一个具备同等原子交接能力的队列。四:紧接着立刻返回 204。dispatcher 可以在崩溃之后重发没送出去的 outbox 行,所以已经确认过的事件不会卡在数据库和队列之间。
互联网上的提供方调不到你笔记本上的 localhost。有官方的 webhook 转发 CLI 就用它,没有就用一个靠得住的 HTTPS 隧道。把对方指向那个临时 HTTPS 地址,但 signature 校验要继续开着,并且用测试模式的密钥。隧道等于把你的机器暴露给真实流量。
# First prove the local route itself works
curl -i -X POST http://127.0.0.1:3000/webhooks/acme \
-H "Content-Type: application/json" \
--data-binary @fixture.json
# Then start the provider CLI/tunnel and register its HTTPS URL:
https://temporary-host.example/webhooks/acme如果 curl 打出 curl: (7) Failed to connect to localhost port 3000: Connection refused,说明那儿根本没有进程在监听。一:把应用启动起来。二:在启动日志里确认它实际监听的端口。三:按你的隧道或容器要求,绑定到对应的网络接口。四:先把本地 curl 跑通,再去查隧道。把一次真实的测试投递存成 fixture,这样你能反复重放同一串字节。
一套好用的 webhook 系统有 inbox 表、队列重试记录,还有 dead-letter 路径。记录事件 ID、类型、接收延迟、校验结果、HTTP 响应、处理尝试次数和最终状态。签名密钥和 authorization 请求头一律不写日志。你的看板应该能很快回答三个问题:收到了吗,接受了吗,之后又发生了什么?
# One searchable structured record
{
"provider":"acme", "event_id":"evt_91", "type":"invoice.paid",
"verified":true, "response_status":204, "queue_id":"job_6",
"processing_state":"succeeded", "attempt":1
}留一个受控的重放工具,按 ID 把已经存下且验证过的事件重新入队。别靠伪造一个新的入站 HTTP 请求来重放,除非你就是想顺便测一下校验。告警要盯队列积压时长和反复失败,而不是每一次 retry。
从边界开始,一层层往里走:对方的投递日志、边缘代理、应用接收、signature 校验、inbox 插入、队列发布、worker 结果。从最终的业务症状开始猜很费时间,因为一个 webhook 能在六个不同的层次上失败。
404 Not Found # wrong public path or deployment
405 Method Not Allowed # route does not accept POST
415 Unsupported Media Type # body parser rejects Content-Type
Webhook signature verification failed. # bytes/secret/header mismatch
504 Gateway Timeout # handler answered too slowly遇到 404,把投递用的那个确切地址复制出来,和已部署的路由对一遍,然后 curl 那个公网地址。遇到 405,在那条路由上注册 POST,并检查是不是有代理改写了方法。遇到 415,看收到的 Content-Type,为那个媒体类型配置成读 raw 字节,别先解析。遇到 signature 校验失败,逐项核对 raw body、这个端点专用的密钥、签名格式和时钟。遇到 504,把业务处理挪到可靠队列后面,并把确认提前。
上线检查清单很短,因为这套设计本来就该很短。
# inbound path
POST only → capture raw bytes → verify signature + timestamp
→ parse JSON → insert unique event ID → durable queue → 204
# worker path
load stored event → idempotent business action → mark succeeded
retry transient failures with backoff → dead-letter permanent failures
# assumptions
duplicates: yes · delayed delivery: yes · out of order: yes
one delivery only: no · secret URL is auth: no · arrival order is truth: no
# debug order
provider log → public URL/status → app receipt → signature
→ inbox row → queue job → worker result
# localhost
local curl first → official forwarder or HTTPS tunnel → test secret我的立场是:接收端要笨,worker 要能干。接收端只要会验证、落库、入队、回话,它就能扛住流量尖峰和不靠谱的依赖。要是它还顺手发收据、更新五张表、调三个 API,那它迟早会把一次五秒的抖动,变成一场重复投递事故。