工作流搭好了,点击 Listen for test event(监听测试事件)时跑得很顺利,你把 URL 粘贴到 Stripe,然后就再也没有动静。没有执行记录。没有报错。平台显示投递失败;更糟的是,平台显示投递成功,你这边却还是什么都没有。
原因不多,引出的困惑却很多,主要是因为 n8n 出错时几乎没有提示,而最常见的原因又是一个看起来完全正确的 URL。下面按它们实际导致问题的频率排列。
TL;DR
- 大多数时候,问题出在测试 URL(Test URL)和生产 URL(Production URL)用混了。测试 URL 只在你盯着它的时候监听
- 生产 URL 只有在工作流激活之后才存在
- 响应模式默认立即回复,对大多数平台没问题,但如果你需要返回 challenge 值,就一定会失败
- 做任何签名验证都必须打开 Raw Body(原始请求体),否则 HMAC 永远对不上
- 执行一旦失败,事件就搁浅了。n8n 不会重试收到的 Webhook
- 自建部署在反向代理后面时,会多出一层请求体大小和超时限制,很容易被忽略
1. 你用的是测试 URL(多半就是它)
n8n 的 Webhook 节点会显示两个 URL,它们的行为完全不同:
Test: https://your-instance/webhook-test/abc-123
Production: https://your-instance/webhook/abc-123
只有在你点击了 Listen for test event、并且正看着画布时,测试 URL 才处于监听状态。它记录一次执行,把数据展示给你,然后停止。你一离开这个页面,它对所有请求都返回 404。
用来搭建工作流,这个设计完全正确;用作粘贴到 Stripe 的 URL,就完全错了。而且因为它在开发时一切正常,你心里形成的印象是“这个 Webhook 能用”,真正的原因反而更难看出来。
去看看你在平台上登记的 URL。如果里面有 webhook-test,问题就在这里,后面不用再看了。
2. 工作流没有激活
生产 URL 在工作流激活(发布)时才会注册。未激活的工作流,生产 URL 返回 404,因为在服务器看来,这条路由根本不存在。
坑在于保存不等于激活。你可以整天编辑、保存、关闭一个工作流,生产路由却始终不会更新。同一类的相关问题还有:
- 为了改东西先停用工作流,改完忘了重新激活。这段时间里的每个事件都没了
- 复制一份工作流来测试改动。副本没有激活,接收流量的仍然是原来那个
- 修改了 Webhook 节点的路径,URL 也就变了。平台指向的仍然是旧 URL
检查编辑器里的开关,再确认平台上的 URL 与当前路径完全一致。
3. 响应模式与平台的预期不符
Webhook 节点的 Respond(响应)设置,也就是它的响应模式,决定 n8n 何时回复、回复什么:
| 模式 | 行为 |
|---|---|
| Immediately(立即) | 事件一到就返回 200 和“Workflow was started” |
| When Last Node Finishes(最后一个节点完成时) | 保持连接直到工作流执行完毕,返回最后一个节点的输出 |
| Using 'Respond to Webhook' node(使用 Respond to Webhook 节点) | 由你显式控制状态码和响应体 |
Immediately 是正确的默认值,你应该保持不变。它返回得快,不管工作流要跑多久,你都在每个平台的响应时限之内。
有两种情况它不适用。
验证握手。有些平台要求你在订阅时原样返回一个 challenge 值,比如一个 token 或请求体的哈希。Immediately 回复的是固定消息,订阅会被拒绝。你需要用 Respond to Webhook 节点返回平台期望的确切值。
工作流很慢,又用了 When Last Node Finishes。这时你的响应时间就是整个工作流的运行时间。三个 API 调用加一次 Google Sheets 写入,轻松就要 8 秒,而 Shopify 的响应时限是五秒。平台记下一次超时,然后重试,你的工作流从头再跑一遍。悄悄制造重复行的,就是这个模式。
如果你用的是 When Last Node Finishes,又不是非得在响应里返回输出,就切换到 Immediately。
4. 没开 Raw Body,签名验证失败
如果你在 n8n 里验证 Stripe、Shopify 或 GitHub 的签名,Webhook 节点的 Raw Body 选项必须打开。
平台对它发出的原始字节计算 HMAC。Raw Body 关闭时,n8n 会解析 JSON,之后任何重新序列化它的 Code 节点都会产生不同的字节:键的顺序不同,空白字符不同。数据完全相同,签名却不同。你会盯着一个肉眼看完全正确的请求体,验证却一直失败,这一个小时足以让人抓狂。
打开 Raw Body,然后在解析任何内容之前,对原始字符串计算 HMAC:
const crypto = require('crypto');
const raw = $input.first().binary
? Buffer.from($input.first().binary.data.data, 'base64').toString('utf8')
: $input.first().json.body;
const expected = crypto
.createHmac('sha256', $env.SHOPIFY_SECRET)
.update(raw, 'utf8')
.digest('base64');
const received = $input.first().json.headers['x-shopify-hmac-sha256'];
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
throw new Error('invalid signature');
}
return { json: JSON.parse(raw) };
用 timingSafeEqual,不要用 ===。另外注意,长度不一致时它会抛出异常,如果传给它的是不可信的输入,要先做好防护。各平台的签名格式详见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。
5. 执行一旦失败,事件就搁浅了
这是真正会造成金钱损失的一种,而且它不是 bug。这个模型本来就是这样工作的。
Respond 设为 Immediately 时,n8n 在工作流运行之前就已经返回了 200。平台认为事件已经投递,永远不会再发。如果第 4 个节点随后因为 token 过期、限流或字段格式错误而失败,这次执行会被标记为失败,而这个事件除了那条失败的执行记录,在哪里都不存在。
收到的 Webhook 没有自动重试。它前面也没有队列。平台不会重发,因为在它看来,一切正常。
应对办法:
- 在工作流设置里配置 Error Workflow(错误工作流)。它至少要能通知你。静默失败是默认行为,而且不是一个好的默认行为
- 把保存原始请求体作为第 1 个节点。在做任何转换之前,先把请求体写进 Postgres、Airtable、Google Sheet,任何持久化的存储都行。这样失败的执行可以从你自己的存储里重放,而不是就此丢失
- 只在允许部分成功的节点上有意识地使用
continueOnFail,不要用在会悄悄吞掉你需要看到的失败的节点上 - 检查保留期。自建的 n8n 会定期清理执行数据,一旦被清理,连失败执行的请求体也没了
6. 自建部署:反向代理挡在中间
如果你是自建部署,放在 nginx、Traefik 或 Caddy 后面,有几层可能在 n8n 看到请求之前就把它吞掉:
- 请求体大小限制。nginx 的
client_max_body_size默认是 1 MB,超过就返回413。Shopify 的批量请求体或 base64 编码的附件都会超过这个值 - 代理超时。如果
proxy_read_timeout比你的工作流运行时间短,代理会关闭连接,即使 n8n 已经执行完毕,平台也会记一次失败 - 没有设置
WEBHOOK_URL。n8n 用它生成界面上显示的 URL。在代理后面,它会显示内网地址,在你的笔记本上能访问,从公网却访问不了 - 队列模式下的 worker。队列模式中,Webhook 必须到达主实例,路由到 worker 会得到 404
最快的诊断方法:从你的网络外部对生产 URL 执行 curl -X POST。404 说明是路由或激活问题。413 说明是请求体大小。请求一直挂着不返回,说明是超时。返回 200 但 Executions(执行记录)里什么都没有,说明这次运行没有被保存:去工作流设置里检查保存生产执行记录的选项。
7. 请求到了,但被过滤条件丢掉了
这种情况不太常见,但值得排除:执行记录存在,却好像什么都没做。对照真实请求体的结构,检查 IF 或 Switch 节点的条件,因为平台的嵌套方式往往和你搭建时用的测试请求体不同。如果根本没有执行记录,检查节点上的 HTTP Method,它默认是 GET,平台向只接受 GET 的 Webhook 发送 POST,会得到 404,看起来和路由问题完全一样。
AnyHook 能做什么
原因 5 是结构性的。n8n 负责执行工作流。它不会先把事件保存下来,它返回 200 的那一刻,事件唯一的另一份副本在平台手里。
在前面加一层 AnyHook,“已投递”的含义就变了。你把平台指向 in.anyhook.net/you/app,再把 AnyHook 指向 n8n 的生产 URL,只改两个 URL,不用重写工作流。之后:
- 事件在调用 n8n 之前就已持久化,执行失败的事件可以从日志里重放
- 对你的 n8n 实例按指数退避重试,升级时实例重启,之后会自动恢复
- 签名验证在边缘节点完成。平台的密钥只需配置一次,你收到的是统一的
AnyHook-Signature请求头,原因 4 里围绕 Raw Body 的那套折腾,就变成一个 HMAC 校验,写一次,所有平台通用 - 连续失败 1 次、5 次和 20 次时发送失败告警,并自动暂停,避免出错的工作流引发重试风暴
实际中最要紧的是这个场景:一次 n8n 升级让实例停机十分钟,这十分钟里的每个事件都可能丢掉。前面有一层转发服务的话,代价只是点一下重放。
设置过程不需要写代码:
- 在 AnyHook 里创建一个 App。如果发送方在来源列表里(Shopify 就在),选它作为来源;不在列表里的(比如 Typeform)选 Generic。把转发目标设为工作流的生产 Webhook URL。
- 在发送方那边,把 Webhook URL 换成这个 App 的接收 URL。在 Shopify 后台是 Settings → Notifications → Webhooks;在 Typeform 里是 Connect → Webhooks。
- 如果工作流以 n8n 自带的 Shopify Trigger 或 Typeform Trigger 节点开头,把它换成普通的 Webhook 节点。Trigger 节点会自己向平台注册 Webhook,这样就绕过了 AnyHook。
- 宕机过后,在 AnyHook 里打开这个 App,从失败事件横幅里重放。
一个 App 对应一个发送方,Free 套餐只有一个 App,所以第二个发送方就需要 Pro 套餐。
总结
先检查 URL。webhook-test 还是 webhook,这个答案出现的频率远高于其他任何原因,激活问题紧随其后。能正常接收之后,趁还没忘,先把持久化补上:第一个节点就写入原始请求体,再配置一个错误工作流。n8n 不会重发它丢掉的东西,平台也不会。