你建好了工作流程,按下「Listen for test event」(監聽測試事件)時跑得很順,把網址貼進 Stripe 之後,卻什麼都沒發生。沒有執行紀錄,也沒有錯誤。服務商說傳遞失敗;更糟的是,它說傳遞成功,你這邊卻什麼都沒有。
原因就那幾個,卻能讓人困惑很久。主要是因為 n8n 出錯時很安靜,而最常見的原因,是一個看起來完全正確的網址。以下依它們實際造成問題的頻率排列。
重點摘要
- 多數時候,答案是 Test URL(測試網址)和 Production URL(正式網址)的差別。Test URL 只在你看著它時才接收。
- Production URL 要等工作流程啟用後才存在。
- 回應模式預設是立刻回覆。這對多數服務商沒問題,但如果你需要回傳 challenge,就是致命傷。
- 任何簽章驗證都必須開啟 Raw Body(原始請求主體),否則 HMAC 永遠對不上。
- 執行失敗,事件就擱淺了。n8n 不會重試收到的 Webhook。
- 自架在反向代理後面,會多一層請求主體大小和逾時的限制,很容易被忘記。
1. 你用的是 Test URL(多半就是這個)
n8n 的 Webhook 節點會顯示兩個網址,兩者的行為完全不同:
Test: https://your-instance/webhook-test/abc-123
Production: https://your-instance/webhook/abc-123
Test URL 只在你按下「Listen for test event」、而且停在畫布上時才有效。它只記錄一次執行,把資料顯示給你看,然後就停止。你一離開這個畫面,它對所有請求都回 404。
開發時,這正是對的設計;拿來貼進 Stripe,就完全錯了。而且因為開發期間它一切正常,你心裡會認定「Webhook 是好的」,真正的原因反而更難看出來。
去看看你在服務商那邊登記的網址。如果裡面有 webhook-test,問題就是這個,後面不用再看了。
2. 工作流程沒有啟用
Production URL 要在工作流程啟用(發布)時才會註冊。未啟用的工作流程,它的 Production URL 會回 404,因為對伺服器來說,這個路由根本不存在。
陷阱在於:儲存不等於啟用。你可以整天編輯、儲存、關閉工作流程,正式路由卻從來沒有更新。同一類的相關錯誤還有:
- 為了修改先停用,改完卻忘了重新啟用。這段期間的每個事件都沒了。
- 複製一份工作流程來測試修改。副本沒有啟用,實際接收流量的仍是原本那份。
- 改了 Webhook 節點的路徑,網址也跟著改變。服務商卻還指向舊網址。
檢查編輯器裡的啟用開關,也確認服務商那邊的網址和目前的路徑完全一致。
3. 回應模式不符合服務商的預期
Webhook 節點的 Respond(回應)設定,也就是它的回應模式(response mode),決定 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 節點,產生的位元組都不一樣:鍵的順序不同,空白也不同。資料一模一樣,簽章卻對不上。你會盯著一份看起來完全正確的 payload,驗證卻一直失敗,這一個小時真的會讓人抓狂。
開啟 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(錯誤處理工作流程)。至少要讓它通知你。預設是安靜地失敗,這不是好的預設。
- 把保存原始 payload 當成第 1 個節點。在任何轉換之前,先把請求主體寫進 Postgres、Airtable、Google Sheet,任何能持久保存的地方都可以。這樣失敗的執行就能從你自己的儲存重播,而不是就此遺失。
- 只在可以接受部分成功的節點上刻意使用
continueOnFail。如果它會悄悄吞掉你需要看到的失敗,就不要用在那個節點上。 - 檢查保存期限。自架的 n8n 會定期清除執行資料,清掉之後,連失敗執行的 payload 也沒了。
6. 自架環境:反向代理擋在前面
如果你自架在 nginx、Traefik 或 Caddy 後面,有幾層可能在 n8n 看到請求之前就把它吃掉:
- 請求主體大小上限。nginx 的
client_max_body_size預設是 1MB,超過就回413。Shopify 的大量 payload 或 base64 編碼的附件都會超過。 - 代理逾時。如果
proxy_read_timeout比你的工作流程短,代理會先關閉連線,服務商記下一次失敗,即使 n8n 其實已經跑完。 - 沒有設定
WEBHOOK_URL。n8n 顯示的網址是用它產生的。在代理後面,它會顯示內部位址,從你的筆電連得到,從網際網路卻連不到。 - 佇列模式(queue mode)的 worker。在佇列模式下,Webhook 必須送到主執行個體,轉到 worker 會得到 404。
最快的診斷方式:從你的網路外部,對 Production URL 執行 curl -X POST。回 404,是路由或啟用的問題。回 413,是請求主體大小。一直沒有回應,是逾時。回 200 但 Executions(執行紀錄)裡什麼都沒有,代表這次執行沒有被儲存:到工作流程的設定裡,檢查儲存正式環境執行紀錄(production executions)的選項。
7. 事件有到,卻被篩選條件丟掉
比較少見,但值得排除:執行紀錄存在,看起來卻什麼都沒做。拿真實的 payload 結構,比對 IF 或 Switch 節點的條件,因為服務商的巢狀結構,常和你開發時用的測試 payload 不一樣。如果根本沒有執行紀錄,就檢查節點的 HTTP Method(請求方法),它預設是 GET。服務商用 POST 打一個只收 GET 的 Webhook,會得到 404,看起來和路由問題一模一樣。
AnyHook 的角色
第 5 個原因是結構性的。n8n 負責執行工作流程,它不會先儲存事件。它一回 200,除了這次執行,唯一的另一份副本就在服務商手上。
在前面放一層 AnyHook,「已送達」的意思就不一樣了。把服務商指向 in.anyhook.net/you/app,再把 AnyHook 指向你的 n8n Production URL:只改兩個網址,不用改寫工作流程。之後:
- 呼叫 n8n 之前,事件就已經儲存,所以失敗的執行可以從事件紀錄重播。
- 對 n8n 執行個體的重試採用指數退避,所以升級期間重新啟動,也會自己恢復。
- 簽章驗證在邊緣節點完成。服務商的密鑰只要設定一次,收到的就是單一的
AnyHook-Signature標頭。第 4 個原因裡那套 Raw Body 的繁瑣步驟,就變成一個 HMAC 檢查,寫一次,所有服務商通用。 - 連續失敗 1、5、20 次時發出失敗警示,並自動暫停,避免壞掉的工作流程引發重試風暴。
實務上真正重要的是這一點:一次讓執行個體停機十分鐘的 n8n 升級,可能讓你損失這十分鐘裡的每個事件。前面有一層 Webhook 中繼服務(webhook relay),代價就只是按一下重播。
設定完全不用寫程式:
- 在 AnyHook 建立一個 App(一個接收網址,對應一個 Webhook 來源)。如果發送端在清單上(Shopify 有),就選它當來源;不在清單上的,例如 Typeform,就選 Generic。目的地設成工作流程的 Production URL。
- 在發送端,把 Webhook 網址換成這個 App 的接收網址(inbound URL)。在 Shopify 管理介面,路徑是「設定 > 通知 > Webhook」(Settings → Notifications → Webhooks);在 Typeform 是 Connect → Webhooks。
- 如果工作流程是從 n8n 內建的 Shopify 或 Typeform Trigger 節點開始,把它換成一般的 Webhook 節點。Trigger 節點會自己向服務商註冊 Webhook,這樣就會繞過 AnyHook。
- 服務中斷之後,在 AnyHook 打開這個 App,從失敗事件的橫幅重播。
一個 App 對應一個發送端,Free 方案可以建一個 App,所以第二個發送端就需要 Pro 方案。
結論
先檢查網址。webhook-test 和 webhook 的差別,遠比其他原因更常是答案,緊追在後的是工作流程沒啟用。收得到事件之後,趁還記得,先處理持久保存:第一個節點就寫入原始 payload,再設定 Error Workflow。n8n 不會重送它丟掉的東西,服務商也不會。