IT技術ブログ

LINE公式アカウントのWebhookをCloudflare Workersで受ける最小構成。署名検証、先に200を返す処理、再送と重複の扱い、チャネルシークレットの置き場所

LINEのWebhookをCloudflare Workersで受ける最小構成を手元で動かして確かめました。Web Cryptoでの署名検証、waitUntilで先に200を返す作り、再送の重複の判定、シークレットの置き場所まで。

この記事の結論:LINE公式アカウントのWebhookをCloudflare Workersで受けるときは、署名の検証、先に200を返すこと、重複の除外の3つを最小構成にします。具体的には、①本文を文字列のまま受け取ってWeb Cryptoで署名を検証し、②検証が通ったらctx.waitUntil()に処理を渡して先に200を返し、③webhookEventIdを主キーにして記録し、処理済みのイベントは飛ばします。チャネルシークレットはwrangler secret putで登録し、手元では.dev.varsに置いてgitに入れません。

この記事のコードは、手元でwrangler devを起動し、署名を自分で計算したテスト用のリクエストを送って動きを確かめたものです。LINEの本番のチャネルにはつないでいません。そのため、LINEから実際に届くWebhookでの動作や、本番環境での速さについては書いていません。確かめた内容とバージョンは、後半の「動作確認した環境」にまとめています。

LINEのWebhookを受ける最小構成は、署名検証・先に200・重複の判定の3つ

最小構成で欠かせないのは、届いたリクエストが本当にLINEからのものかを確かめること、LINEを待たせないこと、同じイベントを2回処理しないことの3つです。返信の文面や予約の照会のような業務の処理は、この3つの外側に足していきます。

役割 この記事での作り方 抜けたときに起きること
送り主の確認 x-line-signatureをHMAC-SHA256で検証 誰でもWebhookのURLに偽のイベントを送れる
LINEを待たせない ctx.waitUntil()で処理を後に回し、先に200 処理が重いと、次のリクエストが待たされる
重複の判定 webhookEventIdを主キーにしてD1に記録 再送で、同じ通知や登録が2回行われる
秘密情報の置き場所 wrangler secret putと.dev.vars チャネルシークレットがリポジトリに残る

LINE Developersのドキュメントでは、Webhookのイベントを処理する前に署名を検証すること、イベントは非同期で処理することが勧められています。また、同じイベントが複数回届くことがあるため、重複の判定にはwebhookEventIdを使うよう書かれています。

ファイルの構成は次のとおりです。

line-webhook/
├── src/index.js      … Worker 本体
├── schema.sql        … D1 の表
├── test.mjs          … 署名付きのテスト用リクエストを送るスクリプト
├── wrangler.jsonc
├── .dev.vars         … 手元用のチャネルシークレット(gitに入れない)
└── .gitignore

署名検証は、本文を文字列のまま受け取ってWeb Cryptoで確かめる

署名は、チャネルシークレットを鍵にして、リクエストの本文をHMAC-SHA256で計算し、Base64にした値です。Workersではcrypto.subtleが使えるので、追加のライブラリなしで検証できます。

大事なのは、request.json()ではなくrequest.text()で本文を受け取ることです。LINEのドキュメントでも、本文を加工せずに検証するよう注意書きがあります。JSONとして読んでから文字列に戻すと、空白や改行、\nのようなエスケープの表し方が変わり、署名が合わなくなります。

src/index.jsjs
// 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()を使い、計算した値と届いた値を自分で===で比べることはしていません。HMACの検証にverifyを使えば、比較までWeb Cryptoに任せられます。Base64として読めない値が届いたときに例外で落ちないよう、atobはtryで囲んでいます。

署名が合わないときは401を返し、本文には一切手を付けません。イベントが0件のリクエストも署名が合っていれば200を返します。0件のときにエラーを返す作りにすると、接続の確認のつもりで送ったリクエストまで失敗扱いになるからです。

先に200を返し、時間のかかる処理はwaitUntilに渡す

Workersでは、ctx.waitUntil()に処理を渡すと、レスポンスを返したあとも処理を続けられます。署名の検証が通った時点で200を返し、返信やデータベースの更新はその後に回します。

違いを確かめるため、業務の処理に3秒かかる場合を作り、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に渡した処理には時間の上限があります。Cloudflareのドキュメントでは、レスポンスを返してから(または相手が接続を切ってから)最大30秒まで延長でき、それを過ぎた処理は取り消されると書かれています。外部のAPIを何度も呼ぶような長い処理や、必ずやり直したい処理は、Cloudflare Queuesに渡して別のWorkerで処理する形が案内されています。

もう1つ気をつけたいのは、200を返した時点で、LINEにとっては「届いた」ことになる点です。LINEの再送は、ボットのサーバーが2xxを返さなかったときに行われる仕組みです。先に200を返す作りでは、後の処理で失敗しても再送は来ないため、失敗したイベントは自分の側で見つけてやり直す必要があります。

再送と重複は、webhookEventIdを主キーにして判定する

同じイベントが2回届いても1回しか処理しないように、webhookEventIdを主キーにした表へ先に記録し、記録できたときだけ処理します。isRedeliveryは判定には使わず、記録として残します。

LINEのドキュメントでは、Webhookの再送を有効にしている場合、2xxが返らなかったイベントが送り直されると説明されています。再送されたイベントの中身は元と同じで、違うのはdeliveryContext.isRedeliveryの値だけです。再送の回数や間隔は公開されておらず、届くことも保証されていません。また、ネットワークの経路の問題などで、再送の設定とは関係なく同じイベントが複数回届くことがあるとも書かれています。

ここから、次のように扱います。

  • isRedeliveryがtrueでも捨てない。最初の配信が届いていなければ、それが初めて受け取るイベントになる
  • 判定はwebhookEventIdが処理済みかどうかで行う
  • 再送では、届く順番が起きた順と入れ替わることがある。順番が意味を持つ処理は、イベントのtimestampで前後を確かめる

D1の表は次のとおりです。

schema.sqlsql
-- 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'))
);

「すでに記録があるか調べてから記録する」という2段階にすると、同じイベントがほぼ同時に2回届いたとき、両方が「まだない」と判断してしまうことがあります。そこで、INSERT ... ON CONFLICT DO NOTHINGの1文で記録を試み、result.meta.changesが0なら処理済みとして飛ばしています。判定をデータベースの主キーに任せる考え方は、予約の二重受付を防ぐ実装とも共通します。

statusは、処理の結果を残すための列です。failedのほかに、receivedのまま残っているものも見ておきます。waitUntilの時間切れなどで、処理が途中で止まった可能性があるからです。

なお、Workers KVを重複の判定に使う例も見かけますが、KVは書き込みが各地に届くまでに時間がかかることがあるため、「同時に届いた2つのうち片方だけを通す」用途には向きません。ここでは主キーで判定できるD1を使いました。

チャネルシークレットはwrangler secretで登録し、手元では.dev.varsに置く

チャネルシークレットは、本番ではwrangler secret putでWorkerのシークレットとして登録し、手元の開発では.dev.varsに書きます。どちらもコードからはenv.LINE_CHANNEL_SECRETで読めます。

bash
# 本番の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
.wrangler

Cloudflareのドキュメントでも、.dev.varsと.envはgitに入れず、.gitignoreに加えるよう書かれています。wrangler.jsoncのvarsは設定ファイルに値が残るため、シークレットには使いません。wrangler secret putは実行するとすぐに新しいバージョンとして反映される点も、ドキュメントに書かれています。

jsonc
// 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のチャネルにつながなくても、Node.jsのcryptoで同じ方法の署名を計算すれば、手元のWorkerに「LINEから届いたのと同じ形」のリクエストを送れます。正しい署名だけでなく、鍵違い・本文の書き換え・整形し直し・再送・0件・ヘッダーなしも送り、それぞれの結果を確かめます。

test.mjsjs
// 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, "");

手順は次のとおりです。

bash
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"

既定のポート(8787)と調べ物用のポートがほかの開発サーバーとぶつかったため、--portと--inspector-portを指定しています。ぶつからなければnpx wrangler devだけで構いません。

テスト用のスクリプトの出力は次のとおりでした。8番は、処理の中でわざと例外を起こすように1行だけ変えた版のWorkerに送ったものです。

1 正しい署名: 200
2 違う鍵で作った署名: 401
3 本文だけ書き換え: 401
4 整形し直した本文: 401
5 同じIDの再送: 200
6 イベント0件: 200
7 署名ヘッダーなし: 401
8 処理が失敗するイベント: 200

wrangler devのログ(関係する行だけ)では、5番の再送が処理されずに飛ばされ、8番は200を返したあとで失敗として記録されています。

[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 failure

D1に残った記録です。再送は2行目にならず、最初の1行だけが残っています。

webhook_event_id             is_redelivery  status
01TESTEVENT0000000000000001  0              done
01TESTEVENT0000000000000002  0              failed

4番の結果は見落としやすいところです。中身がまったく同じJSONでも、整形し直すと署名は合いません。途中にプロキシやフレームワークを挟む場合も、本文が加工されずにWorkerまで届くかを、同じテストで確かめておきます。

本番につなぐ前に決めておくこと

ここまでの最小構成は、受け取りの土台にあたります。LINE Developersコンソールで本番のWebhook URLを設定する前に、次の点を決めておきます。

決めること 考え方
Webhookの再送を有効にするか 有効にすると、2xxを返せなかったときの取りこぼしが減る。重複の判定は必須になる
失敗したイベントを誰が、いつ拾うか statusがfailedやreceivedのままの行を見る仕組みと、通知の先
長い処理をどこで動かすか 30秒に収まらない処理はQueuesに渡す
記録をいつまで残すか イベントにはユーザーIDやメッセージの本文が入る。保存の期間と見られる人を決める
ログに何を出すか 本文やユーザーIDをそのままログに出さない設計にする

Webhookの受け口を作る前の段階、つまり既製の拡張サービスを契約するか、自前で組むかで迷っている場合は、会社のコラムLステップか自社開発か|LINE公式アカウントの選び方が判断の材料になります。株式会社bundlyzeでは、LINE公式アカウントにつなぐ連携の仕組みを自前で作ってきました。この記事のような受信側の実装を含め、業務システムとして作る場合の窓口はシステム開発のページです。

受け取ったイベントを予約の受付にどう使うかは、ジムを例にしたLINEでの体験予約の記事で扱っています。外から届くリクエストをWorkersの側で確かめる別の例として、フォームのスパム対策の記事ではTurnstileのトークンを検証しています。

動作確認した環境

2026年10月4日に、手元のMacで次の環境を使って確かめました。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2(テスト用スクリプトの実行)
wrangler 4.147.0(wrangler dev、wrangler d1 execute --local)
D1 wrangler devの手元のD1(--local)
compatibility_date 2026-10-01

確かめたのは、署名の検証(正しい署名・鍵違い・本文の書き換え・整形し直し・ヘッダーなし)、イベント0件の扱い、同じwebhookEventIdの再送の飛ばし方、処理の失敗の記録、waitUntilとawaitの応答時間の違いです。LINEのチャネルからの実際のWebhook、本番のCloudflareへのデプロイ、wrangler secret put、LINEへの返信(reply)の送信は行っていません。

根拠にした公式ドキュメント(2026年10月4日に確認)

よくある質問

LINEのWebhookの署名検証で、JSONを読み込んでから検証してはいけないのはなぜですか?

署名は、LINEが送ってきた本文の文字列そのものから計算されているからです。一度JSONとして読み込んでから文字列に戻すと、空白や改行、文字のエスケープが変わり、中身が同じでも署名が合わなくなります。手元の確認でも、整形し直しただけの本文は401になりました。本文は文字列のまま受け取り、検証が通ってからJSONにします。

Workersで先に200を返すと、処理に失敗したときはどうなりますか?

LINEには成功と伝わっているので、LINEからの再送はありません。失敗したイベントは自分の側で拾い直す必要があります。この記事の例では、webhookEventIdごとに処理の状態をD1に残し、失敗したものを後から探せるようにしています。確実にやり直したい処理は、Cloudflare Queuesに渡す形も検討します。

isRedeliveryがtrueのイベントは捨ててよいですか?

捨ててはいけません。isRedeliveryがtrueでも、最初の配信が届いていなかった場合は、それが初めて届いたイベントです。判断はwebhookEventIdが処理済みかどうかで行い、isRedeliveryは記録と調査のために残します。

チャネルシークレットはどこに置けばよいですか?

本番はwrangler secret putでWorkerのシークレットとして登録し、手元の開発では.dev.varsに書きます。.dev.varsはgitに入れないよう.gitignoreに加えます。wrangler.jsoncのvarsやソースコードに直接書くと、リポジトリの履歴に残ってしまいます。