LINE公式アカウントのWebhookをCloudflare Workersで受ける最小構成。署名検証、先に200を返す処理、再送と重複の扱い、チャネルシークレットの置き場所
LINEの
このctx.waitUntil()にwebhookEventIdをwrangler secret putで.dev.varsに
このwrangler devを
LINEのWebhookを受ける最小構成は、署名検証・先に200・重複の判定の3つ
最小構成で
| 役割 | この |
抜けた |
|---|---|---|
| 送り主の |
x-line-signatureを |
誰でも |
| LINEを |
ctx.waitUntil()で |
処理が |
| 重複の判定 | webhookEventIdを |
再送で、 |
| 秘密情報の |
wrangler secret putと.dev.vars |
チャネルシークレットが |
LINE DevelopersのwebhookEventIdを
ファイルの
line-webhook/
├── src/index.js … Worker 本体
├── schema.sql … D1 の表
├── test.mjs … 署名付きのテスト用リクエストを送るスクリプト
├── wrangler.jsonc
├── .dev.vars … 手元用のチャネルシークレット(gitに入れない)
└── .gitignore署名検証は、本文を文字列のまま受け取ってWeb Cryptoで確かめる
署名は、crypto.subtleが
大事なのは、request.json()ではなくrequest.text()で\nのような
// src/index.js
// LINE公式アカウント(Messaging API)の Webhook を受ける最小構成
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method !== "POST" || url.pathname !== "/webhook") {
return new Response("Not Found", { status: 404 });
}
// 1. 本文は JSON として読む前に「そのままの文字列」で受け取る
const rawBody = await request.text();
const signature = request.headers.get("x-line-signature") ?? "";
// 2. 署名を検証する。合わなければ処理しない
const ok = await verifySignature(env.LINE_CHANNEL_SECRET, rawBody, signature);
if (!ok) {
console.log("signature mismatch");
return new Response("Unauthorized", { status: 401 });
}
// 3. 検証が通ってから JSON にする
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Bad Request", { status: 400 });
}
const events = Array.isArray(payload.events) ? payload.events : [];
// 4. 重い処理はレスポンスを返したあとに回し、先に 200 を返す
ctx.waitUntil(handleEvents(env, events));
return new Response("OK", { status: 200 });
},
};
async function verifySignature(secret, rawBody, signature) {
if (!secret || !signature) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
let sigBytes;
try {
sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
} catch {
return false; // Base64 として読めない署名
}
// verify は計算した値との比較まで行う(自前で文字列比較しない)
return crypto.subtle.verify("HMAC", key, sigBytes, enc.encode(rawBody));
}
async function handleEvents(env, events) {
for (const event of events) {
const id = event.webhookEventId;
if (!id) continue;
// 5. webhookEventId を主キーにして記録し、すでにあれば処理しない
const result = await env.DB.prepare(
`INSERT INTO line_events (webhook_event_id, type, is_redelivery, event_timestamp)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (webhook_event_id) DO NOTHING`,
)
.bind(id, event.type, event.deliveryContext?.isRedelivery ? 1 : 0, event.timestamp ?? 0)
.run();
if (result.meta.changes === 0) {
console.log(`skip duplicate ${id} (isRedelivery=${event.deliveryContext?.isRedelivery})`);
continue;
}
try {
await processEvent(env, event);
await setStatus(env, id, "done");
console.log(`processed ${id} ${event.type}`);
} catch (err) {
// 200 はもう返しているので、LINE からは再送されない。失敗は自分で拾い直せるように残す
await setStatus(env, id, "failed");
console.log(`failed ${id}: ${err}`);
}
}
}
function setStatus(env, id, status) {
return env.DB.prepare("UPDATE line_events SET status = ?1 WHERE webhook_event_id = ?2")
.bind(status, id)
.run();
}
async function processEvent(env, event) {
// ここに業務の処理を書く(予約の照会、担当者への通知、返信など)
if (event.type === "message" && event.message?.type === "text") {
console.log(`text from ${event.source?.userId}: ${event.message.text}`);
}
}署名のcrypto.subtle.verify()を===でverifyをatobはtryで
署名が
先に200を返し、時間のかかる処理はwaitUntilに渡す
Workersでは、ctx.waitUntil()に
違いをwaitUntilにwrangler devでの
| 作り方 | テスト用リクエストが |
wrangler dev の |
|---|---|---|
ctx.waitUntil(handleEvents(...))で先に返す |
200、38ms | POST /webhook 200 OK (16ms) のprocessed ... |
await handleEvents(...)で |
200、3033ms | processed ... のPOST /webhook 200 OK (3012ms) |
waitUntilに
もう
再送と重複は、webhookEventIdを主キーにして判定する
同じwebhookEventIdをisRedeliveryは
LINEのdeliveryContext.isRedeliveryの
ここから、
isRedeliveryがtrueでも捨てない。 最初の 配信が 届いていなければ、 それが 初めて 受け取る イベントに なる - 判定は
webhookEventIdが処理済みか どうかで 行う - 再送では、
届く 順番が 起きた 順と 入れ替わる ことがある。 順番が 意味を 持つ処理は、 イベントの timestampで前後を 確かめる
D1の
-- schema.sql
DROP TABLE IF EXISTS line_events;
CREATE TABLE line_events (
webhook_event_id TEXT PRIMARY KEY, -- 重複の判定に使う
type TEXT NOT NULL,
is_redelivery INTEGER NOT NULL, -- 1 なら LINE からの再送
event_timestamp INTEGER NOT NULL, -- 順序の確認に使う(届いた順と一致しないことがある)
status TEXT NOT NULL DEFAULT 'received', -- received / done / failed
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);「すでにINSERT ... ON CONFLICT DO NOTHINGのresult.meta.changesが
statusは、failedのほかに、receivedのwaitUntilの
な
チャネルシークレットはwrangler secretで登録し、手元では.dev.varsに置く
チャネルシークレットは、wrangler secret putで.dev.varsにenv.LINE_CHANNEL_SECRETで
# 本番のWorkerに登録する(値は対話で入力し、画面とファイルに残さない)
npx wrangler secret put LINE_CHANNEL_SECRET# .dev.vars(手元の wrangler dev だけが読む。gitに入れない)
LINE_CHANNEL_SECRET=test-channel-secret-for-local-only# .gitignore
.dev.vars*
.env*
node_modules
.wranglerCloudflareの.dev.varsと.envは.gitignoreにwrangler.jsoncのvarsはwrangler secret putは
// wrangler.jsonc(database_id は手元の確認用の仮の値。本番では wrangler d1 create で作ったIDを入れる)
{
"name": "line-webhook",
"main": "src/index.js",
"compatibility_date": "2026-10-01",
"d1_databases": [
{ "binding": "DB", "database_name": "line-webhook", "database_id": "00000000-0000-0000-0000-000000000000" }
]
}このwrangler secret putは.dev.varsだけで
手元でwrangler devを起動し、署名を計算したリクエストで確かめる
LINEのcryptoで
// test.mjs
// wrangler dev で起動した Worker に、署名付きのテスト用リクエストを送る
import { createHmac } from "node:crypto";
const ENDPOINT = process.env.ENDPOINT ?? "http://localhost:8787/webhook";
const SECRET = "test-channel-secret-for-local-only"; // .dev.vars と同じ値
const sign = (body, secret = SECRET) =>
createHmac("sha256", secret).update(body, "utf8").digest("base64");
const event = (id, isRedelivery, text) => ({
type: "message",
mode: "active",
timestamp: Date.now(),
source: { type: "user", userId: "U00000000000000000000000000000000" },
webhookEventId: id,
deliveryContext: { isRedelivery },
message: { id: "100001", type: "text", quoteToken: "q", text },
replyToken: "00000000000000000000000000000000",
});
async function send(label, body, signature) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "content-type": "application/json; charset=utf-8", "x-line-signature": signature },
body,
});
console.log(`${label}: ${res.status}`);
}
const first = JSON.stringify({ destination: "Uxxxxxxxx", events: [event("01TESTEVENT0000000000000001", false, "予約を確認したい")] });
await send("1 正しい署名", first, sign(first));
await send("2 違う鍵で作った署名", first, sign(first, "wrong-secret"));
const tampered = first.replace("予約を確認したい", "予約を取り消したい");
await send("3 本文だけ書き換え", tampered, sign(first));
const reformatted = JSON.stringify(JSON.parse(first), null, 2);
await send("4 整形し直した本文", reformatted, sign(first));
const redelivered = JSON.stringify({ destination: "Uxxxxxxxx", events: [event("01TESTEVENT0000000000000001", true, "予約を確認したい")] });
await send("5 同じIDの再送", redelivered, sign(redelivered));
const empty = JSON.stringify({ destination: "Uxxxxxxxx", events: [] });
await send("6 イベント0件", empty, sign(empty));
await send("7 署名ヘッダーなし", first, "");手順は
npm i -D wrangler
npx wrangler d1 execute line-webhook --local --file=schema.sql
npx wrangler dev --ip 127.0.0.1 --port 8811 --inspector-port 9311
# 別のターミナルで
ENDPOINT=http://127.0.0.1:8811/webhook node test.mjs
npx wrangler d1 execute line-webhook --local --command "SELECT webhook_event_id, is_redelivery, status FROM line_events"既定の--portと--inspector-portをnpx wrangler devだけで
テスト用の
1 正しい署名: 200
2 違う鍵で作った署名: 401
3 本文だけ書き換え: 401
4 整形し直した本文: 401
5 同じIDの再送: 200
6 イベント0件: 200
7 署名ヘッダーなし: 401
8 処理が失敗するイベント: 200wrangler devの
[wrangler:info] POST /webhook 200 OK (11ms)
text from U00000000000000000000000000000000: 予約を確認したい
processed 01TESTEVENT0000000000000001 message
signature mismatch
[wrangler:info] POST /webhook 401 Unauthorized (5ms)
...
[wrangler:info] POST /webhook 200 OK (1ms)
skip duplicate 01TESTEVENT0000000000000001 (isRedelivery=true)
...
[wrangler:info] POST /webhook 200 OK (2ms)
failed 01TESTEVENT0000000000000002: Error: test failureD1に
webhook_event_id is_redelivery status
01TESTEVENT0000000000000001 0 done
01TESTEVENT0000000000000002 0 failed4番の
本番につなぐ前に決めておくこと
ここまでの
| 決めること | 考え方 |
|---|---|
| Webhookの |
有効に |
| 失敗した |
statusがfailedやreceivedの |
| 長い |
30秒に |
| 記録を |
イベントには |
| ログに |
本文や |
Webhookの
受け取った
動作確認した環境
2026年10月4日に、
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 22.23.2 |
| wrangler | 4.147.0wrangler dev、wrangler d1 execute --local) |
| D1 | wrangler devの--local) |
| compatibility_date | 2026-10-01 |
確かめたのは、webhookEventIdのwaitUntilとawaitのwrangler secret put、
根拠にした公式ドキュメント(2026年10月4日に確認)
- LINE Developers「Receive messages (webhook)」:署名を
検証してから 処理する こと、 非同期での 処理の 推奨、 再送と webhookEventIdによる 重複の 判定、 isRedelivery、届く順番 - LINE Developers「Verify webhook signature」:HMAC-SHA256と
チャネルシークレットに よる 署名、 本文を 加工せずに 検証する こと - LINE Developers「Messaging API reference」:Webhookの
リクエストと イベントの 共通の 項目 - Cloudflare Docs「Context (ctx)」:
ctx.waitUntil()と30秒の 上限、 Queuesの 案内 - Cloudflare Docs「Secrets」:
wrangler secret put、.dev.vars、.gitignoreへの追加 - Cloudflare Docs「Web Crypto」:Workersで
使える crypto.subtle - Cloudflare Docs
「Local development :手元での(D1)」 D1の 使い方 - Cloudflare Docs「How KV works」:KVの
読み取りは 書き込みに 対して 結果 整合である こと - Cloudflare Docs「Cloudflare Queues」:時間の
かかる 処理を 別の Workerに 渡す仕組み
よくある質問
LINEのWebhookの署名検証で、JSONを読み込んでから検証してはいけないのはなぜですか?
署名は、
Workersで先に200を返すと、処理に失敗したときはどうなりますか?
LINEには
isRedeliveryがtrueのイベントは捨ててよいですか?
捨てては
チャネルシークレットはどこに置けばよいですか?
本番は