# n8n webhook 連四週靜默沒觸發：內容指紋去重把失敗件永久擋死

- URL: https://justfly.idv.tw/n8n-webhook-%e9%80%a3%e5%9b%9b%e9%80%b1%e9%9d%9c%e9%bb%98%e6%b2%92%e8%a7%b8%e7%99%bc%ef%bc%9a%e5%85%a7%e5%ae%b9%e6%8c%87%e7%b4%8b%e5%8e%bb%e9%87%8d%e6%8a%8a%e5%a4%b1%e6%95%97%e4%bb%b6%e6%b0%b8/
- 日期: 2026-11-16
- 分類: 技術筆記
- 標籤: n8n, python, server, webhook

![n8n webhook 連四週靜默沒觸發：內容指紋去重把失敗件永久擋死]情境示意圖
環境：n8n 排程節點每週五 09:00 對接收端（Python）送一次 webhook，內容一字不差。症狀：從 10/23 起連四個週五，接收端都沒有處理。n8n 的執行紀錄全部顯示成功，接收端沒有任何 log，兩邊都沒有告警。用內容指紋去重、又讓排程送固定內容的 [webhook](https://en.wikipedia.org/wiki/Webhook) 架構，都有機會踩到同一個洞。

##### 症狀：準時送出、執行成功、什麼都沒發生

n8n 端查不出問題：每週五 09:00 準時送出，節點回 `fired:true`。接收端也查不出問題：沒有錯誤、沒有例外，因為根本沒寫 log。連續四次「成功」加上零 log，是這類故障最難找的形態。

##### 根因：三個各自合理的設計疊在一起

10/23 那次處理跑滿 25 分鐘上限而失敗，原件留在失敗匣。接收端的去重規則是：內容指紋（SHA-256）跟失敗匣裡的件相同，就永久擋掉。每週的觸發訊息一字不差，兩邊比對過指紋完全相同，所以之後每個週五都被當成重複件。

接下來是兩層靜默：

- 接收端擋件時回 `200 dup`，而且不寫 log。對呼叫端來說，這跟成功長得一樣。

- n8n 的觸發節點不看回應碼，錯誤也吞掉，一律回 `fired:true`；workflow 也沒有掛錯誤通知。

單看每一條都說得通：去重是為了避免重複處理，回 200 是為了讓呼叫端不要重試，節點吞錯是預設行為。疊起來的結果，是一次失敗讓同內容的觸發永遠進不去。這跟 [冪等性](https://en.wikipedia.org/wiki/Idempotence) 的目標有關：重送應該無害，但「無害」不等於「靜默丟掉」。

##### 修法

- 接收端：失敗匣只擋失敗後 24 小時，以失敗件的修改時間（mtime）計算，超過就放行。

- 接收端：每一種拒收都寫 log，不要靜默回 200；因失敗匣擋下的件告警一次。

- n8n 端：在 HTTP Request 之後放 Code 節點，讀回應的狀態欄位，不是接收端定義的成功值（例如 `queued`）就 `throw`。

- n8n 端：workflow 設定裡指定 errorWorkflow，讓 throw 會發告警。

```
import os, time, hashlib, logging

FAILED_BLOCK_SECONDS = 24 * 3600  # 失敗件只擋 24 小時

def fingerprint(body: bytes) -> str:
    return hashlib.sha256(body).hexdigest()

def check_failed_dup(body: bytes, failed_dir: str) -> bool:
    """回傳 True 代表要擋（失敗後未滿 24 小時的同內容）。"""
    key = fingerprint(body)
    path = os.path.join(failed_dir, key)
    if not os.path.exists(path):
        return False
    age = time.time() - os.path.getmtime(path)  # 以失敗件修改時間計
    if age < FAILED_BLOCK_SECONDS:
        # 每種拒收都要寫 log，不要靜默回 200
        logging.warning("reject: same as failed item %.0fs ago key=%s", age, key)
        return True
    logging.info("failed item older than 24h, allow key=%s", key)
    return False

# n8n 端（Code 節點放在 HTTP Request 之後），步驟註解：
# 1. 讀 HTTP 回應的狀態欄位
# 2. 不是接收端定義的成功值（例如 queued）就 throw new Error(...)
# 3. workflow 設定裡指定 errorWorkflow，讓 throw 會發告警
```

關鍵在 `os.path.getmtime`：計時起點是失敗件的修改時間，而不是這次請求的時間。失敗滿 24 小時，同內容的下一次觸發就能通過。

##### 驗證

- 放一個失敗件到失敗匣，用同內容觸發，應該被擋，而且 log 有一行 `reject`。

- 把失敗件修改時間改到 24 小時前（例如 `touch -t`），再觸發應該放行。

- 在 n8n 用錯誤 token 觸發，執行應該變成 error，不再是 success。

- 拿舊版程式跑同一組測試，應該有測試失敗（反向驗證）。

這次的結果：接收端測試修正後 28/28，修正前版本 9/28。暫時 workflow 實跑：假 token 得到 error、正式 token 得到 queued、重送得到 error（dup）；舊版程式對假 token 仍然是 success。最後這一條就是「看似成功」的直接證據。

##### 適用範圍與限制

- 適用於用內容指紋去重、而且呼叫端每次送相同內容的排程型 webhook。

- 24 小時放行是取捨：失敗件如果其實已經執行了一部分，同內容重送可能重跑。

- 要根治，得由呼叫端每次帶不同的 id，讓「同一次任務的重送」和「新一週的觸發」在接收端分得開。

- errorWorkflow 實際發出告警這一段，手動執行不會觸發，這次沒有驗到。掛上之後，建議用一次真的失敗的排程執行確認告警送達。

檢查現有環境，最快的做法是比對兩邊指紋：把 n8n 送出的 body 與失敗匣裡的件各算一次 SHA-256，相同就代表正在被擋。

— 邱柏宇

### n8n Webhook Silently Dropped for Four Weeks by Fingerprint Dedup

Environment: an n8n schedule fires a webhook at a Python receiver every Friday at 09:00, with a byte-identical body each time. Symptom: starting 10/23, four Fridays in a row never reached processing. n8n’s execution log showed success every time, the receiver wrote no log, and neither side raised an alert. Any [webhook](https://en.wikipedia.org/wiki/Webhook) setup that dedups by content fingerprint and sends fixed content on a schedule can hit the same hole.

##### Symptom: sent on time, marked success, nothing happened

The n8n side looked clean: the trigger went out at 09:00 each Friday and the node returned `fired:true`. The receiver looked clean too: no error, no exception, because nothing was logged. Four “successes” with zero log lines is the hardest shape of failure to find.

##### Root cause: three reasonable designs stacked

On 10/23 the processing run hit its 25-minute limit and failed, leaving the original item in the failed directory. The receiver’s dedup rule permanently blocks any message whose content fingerprint (SHA-256) matches an item there. The weekly trigger body is identical every week, and comparing the fingerprints on both sides confirmed they matched exactly, so every later Friday was treated as a duplicate.

Then two layers of silence:

- When the receiver blocked a request it returned `200 dup` and wrote no log. To the caller that looks the same as success.

- The n8n trigger node ignored the response code and swallowed errors, always returning `fired:true`; the workflow also had no error notification attached.

Each rule makes sense alone: dedup avoids double processing, 200 keeps the caller from retrying, and swallowing errors is default behavior. Stacked, one failure shut out the same content forever. It touches [idempotence](https://en.wikipedia.org/wiki/Idempotence): a resend should be harmless, but harmless is not the same as silently dropped.

##### The fix

- Receiver: the failed directory blocks only for 24 hours after the failure, measured from the failed item’s modification time; after that the request is let through.

- Receiver: every kind of rejection writes a log line instead of a silent 200; a block caused by the failed directory raises one alert.

- n8n: put a Code node after the HTTP Request node, read the response’s status field, and `throw` if it is not the success value the receiver defines (for example `queued`).

- n8n: set errorWorkflow in the workflow settings so the throw produces an alert.

```
import os, time, hashlib, logging

FAILED_BLOCK_SECONDS = 24 * 3600  # 失敗件只擋 24 小時

def fingerprint(body: bytes) -> str:
    return hashlib.sha256(body).hexdigest()

def check_failed_dup(body: bytes, failed_dir: str) -> bool:
    """回傳 True 代表要擋（失敗後未滿 24 小時的同內容）。"""
    key = fingerprint(body)
    path = os.path.join(failed_dir, key)
    if not os.path.exists(path):
        return False
    age = time.time() - os.path.getmtime(path)  # 以失敗件修改時間計
    if age < FAILED_BLOCK_SECONDS:
        # 每種拒收都要寫 log，不要靜默回 200
        logging.warning("reject: same as failed item %.0fs ago key=%s", age, key)
        return True
    logging.info("failed item older than 24h, allow key=%s", key)
    return False

# n8n 端（Code 節點放在 HTTP Request 之後），步驟註解：
# 1. 讀 HTTP 回應的狀態欄位
# 2. 不是接收端定義的成功值（例如 queued）就 throw new Error(...)
# 3. workflow 設定裡指定 errorWorkflow，讓 throw 會發告警
```

The key is `os.path.getmtime`: the clock starts at the failed item’s modification time, not at the time of the current request. Once the failure is 24 hours old, the next trigger with the same content passes.

##### Verification

- Put a failed item in the failed directory and trigger with the same content: it should be blocked, and the log should contain one `reject` line.

- Move the failed item’s modification time back past 24 hours (for example with `touch -t`) and trigger again: it should be let through.

- Trigger from n8n with a wrong token: the execution should become error, no longer success.

- Run the same tests against the old code: some should fail (reverse validation).

Results here: the receiver tests went 28/28 after the fix versus 9/28 for the pre-fix version. A temporary workflow run gave error for a fake token, queued for the real token, and error (dup) on resend; the old code still returned success for the fake token, which is the direct proof of the “looks fine” problem.

##### Scope and limits

- It applies to scheduled webhooks that dedup by content fingerprint and whose caller sends the same content every time.

- The 24-hour release is a trade-off: if the failed item had already partly executed, a resend with the same content may run it again.

- The real cure is for the caller to send a different id each time, so a retry of one job and a new week’s trigger can be told apart.

- The part where errorWorkflow actually sends an alert does not fire on a manual execution, so it was not verified this time. After attaching it, confirm delivery with one real failing scheduled run.

The fastest way to check an existing setup: compute SHA-256 over the body n8n sends and over the item in the failed directory. If they match, the trigger is being blocked.

— 邱柏宇
