n8nのWebhookをUIなしで有効化し、外部からワークフローを起動する

公開:

本記事には広告(PR)が含まれます。

n8nをUIなしで運用し、外部システムからの通知や連携をWebhookで受けたい人向けの記事。CLIでインポートしたWebhookが実際に応答し始めるまでの遷移を、Docker上のn8n 2.35.4で実測した。

結論

n8nのWebhookは、ワークフローをCLIでインポートしてpublish:workflowで有効化しただけでは404を返し続け、コンテナを再起動して初めて/webhook/<path>が200を返す。再起動後の疎通待ちはhealthzでは足りず、Webhook URL自体が200かつJSONを返すまでポーリングする。

やったことと結果は次のとおり。

やったこと結果
WebhookワークフローをCLIでインポートし、直後にPOST404(not registered)
CLIで有効化し、DBへの反映を確認して再びPOSTそれでも404。実行中のn8nに反映するには再起動が必要
再起動し、healthzとWebhookを交互にポーリングhealthzが200を返した後も、WebhookはHTMLの404を返し続ける
Webhookが応答し始めた時刻の特定応答が返ったのはEditor is now accessible via:ログの0.10秒後(receivedAt基準)
起動途中のWebhook応答の観測HTTP 200でボディがn8n is starting up. Please waitという文字列の瞬間がある

そのまま使える待機ループも本文に載せている。

検証環境

  • OS: Windows 11 Home
  • 実行環境: Docker Desktop(Docker Engine 28.3.3)、セルフホスト
  • イメージ: n8nio/n8n:2.35.4(docker exec n8n-verify n8n --version2.35.4で確認)
  • 起動コマンド: docker run -d --name n8n-verify -p 5678:5678 -v n8n_verify_data:/home/node/.n8n n8nio/n8n:2.35.4
  • シェル: Git Bash(curlはcurl.exe)。PowerShellではcurlInvoke-WebRequestのエイリアスのため、本記事のコマンドはそのままでは動かない
  • N8N_SECURE_COOKIE=falseは付けていない。手順はエディタ画面を開かずに完結しており、後述のUIでの確認でも、localhost経由のアクセスではこの設定なしでログインとエディタ表示ができた
  • 検証日: 2026-08-20 JST。本文のログのタイムスタンプはUTC、Webhook応答のreceivedAtはn8nのデフォルトタイムゾーン(America/New_York、-04:00)

WebhookワークフローをCLIでインポートする

1. Webhookワークフローを用意する

POSTを受けてitem/qty/receivedAtを抽出・付与するだけの最小構成。Webhookノードはpathをorder-received、responseModeをlastNodeに設定している。webhook-echo.jsonとして保存する。

{
  "id": "VerifyWebhook001",
  "name": "webhook-echo",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "order-received", "responseMode": "lastNode", "options": {} },
      "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 0],
      "webhookId": "b2f9d1c4-verify-0001"
    },
    {
      "parameters": { "assignments": { "assignments": [
        { "name": "item", "value": "={{ $json.body.item }}", "type": "string" },
        { "name": "qty", "value": "={{ $json.body.qty }}", "type": "number" },
        { "name": "receivedAt", "value": "={{ $now.toISO() }}", "type": "string" }
      ] }, "options": {} },
      "name": "Edit Fields", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [200, 0]
    }
  ],
  "connections": { "Webhook": { "main": [[ { "node": "Edit Fields", "type": "main", "index": 0 } ]] } },
  "active": false,
  "settings": {}
}

2. CLIでインポートする

docker cp webhook-echo.json n8n-verify:/tmp/webhook-echo.json
docker exec n8n-verify n8n import:workflow --input=/tmp/webhook-echo.json

出力:

Importing 1 workflows...
Successfully imported 1 workflow.

この時点でエディタ画面は一度も開いていない。

publish:workflowで有効化しても404 not registeredが返り続ける

3. インポート直後にPOSTすると404 not registeredが返る

ステータスコードと応答時間を同時に確認できる形でcurlを実行する。

curl -s -w "\nHTTP %{http_code} time_total %{time_total}s\n" -X POST http://localhost:5678/webhook/order-received -H "Content-Type: application/json" -d '{"item":"widget","qty":3}'

出力の全文:

{"code":404,"message":"The requested webhook \"POST order-received\" is not registered.","hint":"The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"}
HTTP 404 time_total 0.315808s

not registeredという明確な理由が返っており、パスの入力ミスではなくWebhookが未登録であることの証明になる。

4. publish:workflowでCLIから有効化する

2.35.4で現行の有効化コマンドはpublish:workflowである。従来のupdate:workflow --active=trueも動作するが、非推奨警告が出てpublish:workflowが案内される。まず従来コマンドの出力の全文:

docker exec n8n-verify n8n update:workflow --id=VerifyWebhook001 --active=true
⚠️  WARNING: The "update:workflow" command is deprecated.

Publishing workflow VerifyWebhook001 with current version
Please use: publish:workflow --id=VerifyWebhook001

Note: Changes will not take effect if n8n is running.
Please restart n8n for changes to take effect if n8n is currently running.

後継のpublish:workflowの出力:

docker exec n8n-verify n8n publish:workflow --id=VerifyWebhook001
Publishing workflow with ID: VerifyWebhook001 (current version)
Note: Changes will not take effect if n8n is running.
Please restart n8n for changes to take effect if n8n is currently running.

どちらのコマンドでも、実行中のn8nに反映するには再起動が必要である旨が明示される。

5. DBへの反映を確認する

有効化が実際にDBへ書き込まれたかをexportコマンドで確認する。

docker exec n8n-verify n8n export:workflow --id=VerifyWebhook001 --pretty --output=/tmp/check-active.json
docker exec n8n-verify grep -o '"active": [a-z]*' /tmp/check-active.json

出力(1行目がexportのstdout、2行目がgrepの結果):

Successfully exported 1 workflow.
"active": true

DBには反映済みである。

6. 有効化後も再起動前は404が返る

手順3と同じcurlコマンドを実行する。出力の全文:

{"code":404,"message":"The requested webhook \"POST order-received\" is not registered.","hint":"The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"}
HTTP 404 time_total 0.020585s

DBのactiveがtrueになっていても、実行中のn8nプロセスにWebhookは登録されない。

再起動後にWebhookが応答し始めるまでを観測する

7. 再起動し、healthzとWebhookを交互にポーリングして遷移を観測する

再起動直後から、同一ループ内でhealthzとWebhookへ交互にリクエストを送り、経過時間と各リクエストのステータスコード・所要時間・ボディ先頭1文字を記録した。実際に実行したコマンドは次のとおり(掲載のまま実行した)。

docker restart n8n-verify > /dev/null
s=$(date +%s.%N)
while :; do
  ts=$(date +%s.%N)
  h=$(curl -s --max-time 2 -o /dev/null -w "%{http_code}/%{time_total}" http://localhost:5678/healthz)
  w=$(curl -s --max-time 5 -o wb.json -w "%{http_code}/%{time_total}" -X POST http://localhost:5678/webhook/order-received -H "Content-Type: application/json" -d '{"item":"widget","qty":3}')
  b=$(head -c 1 wb.json 2>/dev/null)
  awk -v t="$ts" -v r="$s" -v h="$h" -v w="$w" -v b="$b" 'BEGIN{printf "+%.2fs healthz=%s webhook=%s body_starts=%s\n", t-r, h, w, b}'
  case "$w" in 200/*) [ "$b" = "{" ] && break;; esac
  sleep 0.5
done
cat wb.json

出力の全文(各項目はステータスコード/所要時間で、所要時間の単位は秒。経過時間はポーリング1回ごとの開始時点で計測しており、sleep 0.5とcurlなどの所要時間が加わるため行間隔は実測0.77〜1.90秒):

+0.03s healthz=000/0.019020 webhook=000/0.006963 body_starts=
+1.05s healthz=000/0.002900 webhook=000/0.022608 body_starts=
+2.10s healthz=000/0.003323 webhook=000/0.003072 body_starts=
+3.06s healthz=000/0.010244 webhook=000/0.004554 body_starts=
+4.00s healthz=000/0.006039 webhook=000/0.002384 body_starts=
+4.77s healthz=000/0.004304 webhook=000/0.003148 body_starts=
+5.54s healthz=200/0.250737 webhook=404/0.878944 body_starts=<
+7.44s healthz=200/0.108775 webhook=404/0.145649 body_starts=<
+8.43s healthz=200/0.419551 webhook=200/0.379772 body_starts={
{"item":"widget","qty":3,"receivedAt":"2026-08-19T14:22:02.202-04:00"}

同じ再起動のdocker logs -tの該当行:

2026-08-19T18:21:58.024900833Z n8n ready on ::, port 5678
2026-08-19T18:22:02.099417043Z Editor is now accessible via:

なお、本文でreceivedAtと呼ぶ時刻は、Edit Fieldsノードが$now.toISO()を評価した時刻である。ワークフロー処理中の時刻で、リクエストの受け付けと応答の間にある。

経過時間の起点(docker restart完了時刻)は、最後のポーリング1回分の実測値から逆算できる。逆算に使った数値は次のとおり。

  • 最後のWebhookリクエストの処理時刻(receivedAt): 18:22:02.202Z
  • 同リクエストの所要時間: 0.38秒
  • 直前のhealthzリクエストの所要時間: 0.42秒
  • この回のポーリング開始(経過時間): +8.43s

これらから、起点はおおよそ18:21:53.0Zとなる。この対応ではn8n readyログ(18:21:58.025)は起点+5.0秒に当たり、healthzが000だった+4.77sと200だった+5.54sの間に入って観測と整合する。

この試行から読み取れるのは次の5点である。

  • +4.77sまでの000は接続拒否である。各リクエストの所要時間が2〜23msと即時に失敗しており、タイムアウト(healthz 2秒・Webhook 5秒)ではない
  • healthzの200は+5.54sの回から観測され、n8n readyログとほぼ同時に始まる
  • healthzが200を返している間も、Webhookは2回のポーリングにわたりボディ先頭が<のHTML 404を返した。サーバーがWebhook用のルートをまだ持たない起動途中の応答で、稼働中のn8nが返す手順3のJSON 404(not registered)とは別物である。このHTML 404の全文は、同じ起動途中の状態を観測した別の再起動の試行で保存したもので、次のとおり(ボディは同一だが、healthzが200の局面で採取したものではない)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot POST /webhook/order-received</pre>
</body>
</html>
  • 最初のJSON 200のreceivedAt(サーバ側の処理時刻)は18:22:02.202Zで、Editor is now accessibleログの0.103秒後だった。ポーリングの分解能(約1秒)の範囲では、Webhookの応答開始はEditorログとほぼ同時といえる
  • この最後の200リクエストの所要時間は0.38秒で、定常状態(0.043〜0.052秒)の約8倍だった。時刻から逆算するとリクエストの受け付けはEditorログより前で、応答はログの0.10秒後に返っている。起動完了の間際に届いたリクエストは、即座に404になるのではなく、受け付けられたまま待たされて成功することがある

なお別の再起動の試行では、起動途中のWebhook URLがHTTP 200でボディn8n is starting up. Please waitという文字列を返す瞬間も観測した(該当行: +5.93s healthz=000/0.004732 webhook=200/0.023982 body_starts=n)。この瞬間healthzは000であり、healthzを併用していてもこの誤判定は防げない。

ステータスコードの200だけを見て疎通完了と判断すると、起動中に返るこの仮の応答を成功と誤認する。上記ループの完了条件を「200かつボディがJSON(先頭が{)」にしているのはこのためである。

実測結果とつまずいた点

Webhook登録後の定常状態で、手順3と同じcurlを3回実行した出力の全文:

{"item":"widget","qty":3,"receivedAt":"2026-08-19T14:22:37.281-04:00"}
HTTP 200 time_total 0.051547s
{"item":"widget","qty":3,"receivedAt":"2026-08-19T14:22:37.380-04:00"}
HTTP 200 time_total 0.042954s
{"item":"widget","qty":3,"receivedAt":"2026-08-19T14:22:37.465-04:00"}
HTTP 200 time_total 0.046886s

定常状態の応答時間は0.043〜0.052秒だった。

つまずいた点は次の3つ。

  • インポート直後、CLIで有効化した直後、さらにDBのactiveがtrueになった後でもWebhookは404を返し続ける。有効化を反映させるにはコンテナの再起動が必須で、UI上で開いて保存する操作は不要だった。
  • コンテナ再起動後、healthzが200を返してもWebhookはまだ404を返す。さらに起動途中にはHTTP 200でn8n is starting up. Please waitという文字列が返る瞬間もあるため、疎通待ちはステータスコードだけでなくボディがJSONであることまで確認する。そのまま使える待機ループは次のセクションに示す。
  • receivedAtのタイムゾーンが-04:00(America/New_York)になっていた。起動コマンドにGENERIC_TIMEZONETZを指定していないため、n8nのデフォルトタイムゾーンが使われている。日本時間で記録したい場合は起動時に-e GENERIC_TIMEZONE=Asia/Tokyo -e TZ=Asia/Tokyoを付ける必要がある。本検証ではデフォルトのまま実測値として記録した。

なお公式ドキュメントによると、エディタの「Test URL」(/webhook-test/<path>)は、UIでテストイベントを待ち受けている間だけ有効なパスであり、ヘッドレス運用では本番用の/webhook/<path>を使う必要がある。本検証でも/webhook/<path>のみを使用した。

Webhookの準備完了を待つループ

再起動後の疎通待ちにそのまま使える待機ループは次のとおり。ただし本番URLへのPOSTは成功するとワークフローを実行するため、副作用のないテスト用ペイロードで行うこと。

docker restart n8n-verify > /dev/null
date -u +"restart done at %H:%M:%S.%3N UTC"
s=$(date +%s.%N)
until c=$(curl -s --max-time 5 -o body.json -w '%{http_code}' -X POST http://localhost:5678/webhook/order-received -H 'Content-Type: application/json' -d '{"item":"healthcheck","qty":0}'); [ "$c" = "200" ] && [ "$(head -c 1 body.json 2>/dev/null)" = "{" ]; do sleep 1; done
awk -v t="$(date +%s.%N)" -v r="$s" 'BEGIN{printf "HTTP 200 (JSON) elapsed +%.1fs\n", t-r}'
cat body.json

このループを掲載のまま再起動込みで実行した出力:

restart done at 18:30:56.294 UTC
HTTP 200 (JSON) elapsed +8.6s
{"item":"healthcheck","qty":0,"receivedAt":"2026-08-19T14:31:04.676-04:00"}

この試行のdocker logs -tではn8n readyが18:31:00.268、Editor is now accessibleが18:31:04.134で、200のreceivedAt(18:31:04.676Z)はEditorログの0.54秒後だった。手順7と同じ遷移が待機ループでも再現している。

UIでPublished表示を確認する

本記事の手順はエディタ画面を開かずに完結しているが、CLIでの有効化と再起動を経た状態はUIでも確認できる。以下のスクリーンショットは、本記事の構成を同じ検証環境で再現して取得したもので、本文に引用したログとは別の試行である。画面の時刻表示はブラウザのローカル時刻(JST)で、本文ログのUTC表記とは9時間ずれる。

UIの閲覧は検証の本筋ではないため手順には含めない。初回アクセス時のみオーナーアカウントの作成を求められ、メールアドレス・氏名・パスワードを登録する。この環境はN8N_SECURE_COOKIE=falseを付けずに起動しているが、http://localhost:5678/へのアクセスではログインとエディタ表示がそのまま通った。

ワークフロー一覧からwebhook-echoを開くと、右上にPublishedと表示される。手順3の404応答のhintは「toggle in the top-right of the editor」と案内するが、2.35.4のUIにトグルはなく、このPublished表示が有効化状態に当たる。

webhook-echoワークフローをエディタで開いた状態。CLIのpublish:workflowと再起動を経て、右上にPublishedと表示されている

画面上部のExecutionsタブに切り替えると実行履歴が見える。この試行で本ワークフローを起動したのは本番URLへのPOST3回のみで、内訳は再起動後の疎通ポーリングで成功した1回と、その後に実行した2回である。履歴の3件はこれに対応する。実行IDはワークフロー単位ではなくインスタンス全体の通し番号のため、先に別ワークフローをCLIで2回実行したこの試行では、履歴3件に対して最新の詳細表示がID#5になっている。

Executionsタブの実行履歴。3件が成功として記録されている

まとめ

  • n8nのWebhookはインポートやCLIでの有効化だけでは動かず、コンテナの再起動を経て初めて/webhook/<path>が200を返す
  • 再起動後もhealthzの200はWebhook準備完了を意味せず、実測ではhealthzの200開始後もWebhookはHTMLの404を返し続けた
  • ワークフローの応答開始はEditor is now accessible via:ログとほぼ同時で、receivedAt基準でログの0.10秒後だった
  • 起動途中にはHTTP 200でボディがn8n is starting up. Please waitとなる瞬間もある
  • ヘッドレス運用でWebhookの疎通を待つなら、Webhook URL自体が200かつJSONを返すまでポーリングする

出典