深夜23:47、本番Webhookの失敗通知が37件。APIは 400 {"error":"invalid_json"} を返し続けていました。テスト実行は緑のまま。原因は、JSON Bodyに埋め込んだ顧客名に半角引用符が入ったレコードが来たこと。Bodyの組み立てを実行時の文字列評価に任せたままだったため、構文の破壊を送信前に誰も検証できませんでした。この記事では、Body組み立てをCode nodeに集約し、JSON構文とExpressionを送信前に静的検証する完全実装を書きます。
何が起きたか: テストは通る、本番だけ400
| 日時 | 起きたこと |
|---|---|
| 23:47 | 本番Webhook経由の登録処理が400で失敗し始める(1件) |
| 23:52 | retryOnFailが同一リクエストを5回再送、全敗 |
| 00:31 | ダッシュボードに失敗累積37件。応答はすべて invalid_json |
| 02:12 | Bodyの実文字列をログから復元。位置214に " が1個、その次が , |
応答本文(n8nの実行ログから):
400 - BAD_REQUEST
{"error":"invalid_json","detail":"Unexpected token , ... position 214"}
位置214を含むBodyの該当箇所はこうなっていました:
"customer_name": "株式会社"サンプル"ホールディングス",
半角引用符を含む顧客名が、文字列リテラルの引用符を食い破ったのです。ダミーデータ(引用符なしの名前)で実行したテストは当然すべて成功。データが本物になって初めて壊れるタイプの事故でした。
原因の切り分け: なぜ送信前に検証できなかったのか
- n8nはBodyのJSON構文を実行時まで検証しない。HTTP RequestノードのBodyは「実行時に評価される文字列」であり、保存時には何も起きない
- 組み立てがHTTP Requestノードの中に埋まっていた。Body文字列を外から組み立て直して検証する場所がそもそも存在しなかった
- 400を「異常」として扱う係がいなかった。HTTP Requestは400を1つの応答として記録するだけ。throwして止める検証ノードがなければ、失敗はリトライに任されて量だけ増える
やってダメだった直し2つ(失敗パターン)
失敗1: 顧客名の引用符をreplaceAllで剥がした。 "customer_name": "${$json.name.replaceAll('\"','')}" で確かに壊れなくなります。しかしデータは欠損します。JSONとして不正な文字への正解はエスケープであり、除去ではありません。
失敗2: Body全体を {{ JSON.stringify($json) }} 一発に置き換えた。 ネスト設計が失われ全項目を送る形になって、送信先APIの仕様(一部項目は許可リスト制)と衝突しました。組み立て方針は変えずに、組み立てた結果を検証するのが正解です。
正解: 組み立てをCode nodeに集約し、送信前に静的検証する
3ステップだけです。
- 組み立て: Body文字列はCode nodeの1つのテンプレートで組み立てる(HTTP RequestのBody直書きをやめる)
-
検証: JSON.parseで構文を検証し、未評価の
{{ }}の残りを正規表現で検出する - 停止: 失敗したらthrow。下流のHTTP Requestは実行されず、Error Workflowへ流れる
ポイントは「検証ノードを後ろに足す」ではなく「組み立てそのものを移設する」ことです。移設しないと、検証対象と実送信のBodyが別物になり、検証の意味がなくなります。
完全実装(コピペで動く)
Code node(Mode: Run Once for All Items)にそのまま貼ります:
// JSON Body 静的検証つき組み立て(Code node / Run Once for All Items)
const out = [];
for (const item of $input.all()) {
const d = item.json;
// 1) 組み立てはここに集約する(HTTP RequestのBodyには書かない)
const raw = `{
"customer_id": "${d.customerId}",
"customer_name": ${JSON.stringify(String(d.name))},
"amount": ${Number(d.total)},
"currency": "JPY",
"items": ${JSON.stringify(d.items ?? [])}
}`;
// 2) 未評価のExpression残りを検出する({{ }} が残る = 参照ミス・タイポ)
const leftover = raw.match(/\{\{[\s\S]*?\}\}/);
if (leftover) {
throw new Error('未評価のExpressionが残っています: ' + leftover[0].slice(0, 60));
}
// 3) JSON構文を検証する(失敗位置の前後20字を一緒に報告)
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
const m = /position (\d+)/.exec(e.message || '');
const pos = m ? Number(m[1]) : -1;
const around = pos >= 0 ? raw.slice(Math.max(0, pos - 20), pos + 20) : raw.slice(0, 40);
throw new Error('JSON構文エラー: ' + e.message + ' / 該当箇所: ' + JSON.stringify(around));
}
// 4) 全角スペース・全角コロンなど構文への紛混入を検出する
const zenkaku = raw.match(/[\u3000\uFF1A\uFF0C]/);
if (zenkaku) {
const idx = raw.indexOf(zenkaku[0]);
const around = raw.slice(Math.max(0, idx - 20), idx + 20);
throw new Error('JSON構文に全角文字(' + zenkaku[0] + ')が混入しています / 該当箇所: ' + JSON.stringify(around));
}
out.push({ json: { body: parsed } });
}
return out;
HTTP Requestノード側の設定:
| 項目 | 設定 |
|---|---|
| Method | POST |
| Send Body | ON / Body Content Type: JSON |
| Specify Body | Using JSON |
| JSON | {{ JSON.stringify($json.body) }} |
検証が事故を止めるときの出力:
Error: JSON構文エラー: Unexpected token , in JSON at position 214 / 該当箇所: "株式会社\"サンプル\"ホールディングス\",\n..."
フィールドごとにJSON.stringifyしているので引用符入りデータはエスケープされ、検証も毎実行・本番データで走ります。throwで止まるためHTTP Requestは一度も実行されず、retryOnFailの5連敗も構造的に起きなくなります。
比較表: 検証タイミングの3方式
| 方式 | 検証タイミング | 検出できること | コスト | 判定 |
|---|---|---|---|---|
| 実行後のエラー待ち(事故版) | 送信後 | APIが返す400のみ | 影響リクエストが発生してから | ✗ やめる |
| Code node静的検証(本記事) | 送信直前・毎実行 | JSON構文・未評価Expression・全角混入 | ノード1個・数ミリ秒 | ✓ 採用 |
| エクスポートJSONのCI検証 | 改修時 | ワークフロー構造全体 | リポジトリ管理が前提 | △ 併用する |
送信前チェックリスト(保存用)
- Body組み立てはCode node 1箇所に集約したか(HTTP Request直書きでないか)
- 組み立て後にJSON.parseを1回通したか
-
未評価の
{{ }}残りを正規表現で検出しているか - 失敗位置(position)の前後20字を一緒に報告するか
- 失敗時はthrowで下流を止め、Error Workflowへ流しているか
- テスト実行は本番同等のデータ(引用符・全角・複数items)で行ったか
まとめ: 次にやること
- 本番Webhookを持つHTTP Requestノードを開き、Body直書きをやめて上のCode nodeに置き換える(組み立て文字列は自ワークフロー用に3行直すだけ)
- throw時の通知先としてError Workflowを接続する(retryOnFailとの切り分けは関連記事参照)
- 次回のBody編集から「編集 — 静的検証 — 手動実行1回 — 本番」の手順を固定する。そのとき注意: Code node内のテンプレートはバッククォートで組み立てるため、n8nの式({{ }})を中に書かないこと。参照は全部JS(d.xxx)に統一する
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
関連記事(同テーマ)
- n8nのHTTP Requestで送信先APIが400を返し続けた — JSON Body内Expressionが評価されない3つの落とし穴
- n8nのリトライ設定が失敗を5倍に増やした — retryOnFailとエラーワークフローの正しい切り分け
📗 n8n × AI自動化実践入門 — ノーコードで作るAIワークフロー(Kindle・Kindle Unlimited読み放題対象)