課題の整理:なぜ複数チャンネル運用は混乱するのか
LINE公式アカウント(OA)と社内Botチャンネルを並行運用すると混乱が起きるのは、両者のChannel SecretとChannel Access Tokenが完全に独立しているためです。整理さえすれば防げる事故なので、まず仕組みを確認しておきます。
- 01申請利用者がOAチャンネルに「申請:…」と送る
- 02受信/webhook/oa で署名検証して受け取る
- 03通知社内Botチャンネルへ pushMessage
- 04承認担当者が「承認:ID」と返す● 人が確認
- 05返信同じ userId でOAから申請者へ push
OAチャンネルに届いたWebhookイベントはOAのChannel Secretで署名されており、返信にはOAのChannel Access Tokenが必要です。Botチャンネルはこれと別の認証情報を持っています。この2つを混在させると、次のような事故が起きます。
- 署名検証でBotのSecretを使ってしまい、OAのWebhookが400エラーで弾かれる
- OAのトークンでBotチャンネル宛てにpushMessageを呼び、401エラーで送信が失敗する
- どちらのWebhookで受けたイベントかコードを目で追わないと分からなくなり、障害対応が長引く
私の事務所でも、公開しているサイトの定時処理の中でLINEのWebhookを扱っています。その運用で最初につまずいたのも、まさにこのトークンとシークレットの混在でした。詳しくは後半の「自社ではこう使っている」で触れますが、複数チャンネルを扱う以上、最初に設計で分離しておくことが結局いちばん早い対処になります。
同一プロバイダーならuserIdが共通になる仕様と、またいだ場合の落とし穴
同一プロバイダー配下のチャンネルであれば、OAチャンネルで取得したuserIdをそのままBotチャンネルのpushMessageに渡せます。ユーザーIDの変換テーブルを別途作る必要がないのが、この構成の価値です。
これは仕様として明記されている挙動で、LINE API できていますか?プロバイダー/チャネルの正しい管理方法でもプロバイダー単位の管理の重要性が解説されています。OAでユーザーからリクエストを受け、社内BotチャンネルにPush通知を飛ばし、結果をOAチャンネルでユーザーに返す、という一連の流れが、IDの変換処理なしに成立します。
一方で、プロバイダーをまたぐと話は変わります。プロバイダーA配下のOAで取得したuserIdと、プロバイダーB配下のBotで取得したuserIdは、同一人物であっても別の値になります。これは仕様上の制限で、アプリ側での回避策はありません。LINE Developersとは?プロバイダー・チャネルを正しく設定する方法とよくある失敗例でも、プロバイダー設計を誤ったまま進めてしまう失敗例が紹介されています。
- LINE Developersコンソールにログインし、左のプロバイダー一覧を開く
- OAチャンネルとBotチャンネルがどちらの配下にあるかをそれぞれ確認する
- 別プロバイダーに分かれていた場合は、新規チャンネルの作成先を統一するか、既存チャンネルの移管を検討する
チャンネルを後から別プロバイダーに移すのは手間がかかるため、複数チャンネル運用を始める最初の時点でプロバイダーを揃えておくのが安全です。
設計:Webhookをチャンネルごとに分離し、トークンを動的に切り替える
受信はチャンネルごとに独立したエンドポイントで受け、送信はチャンネルごとに生成したクライアントを使い分けるのが、事故の起きにくい設計です。
「1つのエンドポイントに集めてifで分岐すればいいのでは」と考えたくなりますが、署名検証はチャンネルごとのChannel Secretで行う必要があるため、SDKのmiddlewareに検証をそのまま任せられるエンドポイント分離の方が、コードもシンプルになります。
| 項目 | OAチャンネル | Botチャンネル |
|---|---|---|
| Webhookパス | /webhook/oa | /webhook/bot |
| 署名検証に使う値 | OA_CHANNEL_SECRET | BOT_CHANNEL_SECRET |
| 送信に使うトークン | OA_CHANNEL_ACCESS_TOKEN | BOT_CHANNEL_ACCESS_TOKEN |
| 主な用途 | ユーザーへの応答 | 社内担当者への通知・承認受付 |
Messaging APIのreplyMessageとpushMessageは呼び出すエンドポイントが異なりますが、認証の考え方は共通です。Messaging APIリファレンス | LINE Developersを見ながら、クライアントオブジェクトをチャンネルごとに1つずつ持っておく、という方針で次の実装に進みます。
実装:Node.jsでOA・Botの2チャンネルを並行運用する
ここではLINE Developersコンソールでの準備から、申請を受けて承認を通知するまでの実コードを、ngrokでの疎通確認まで含めて書き切ります。
Step1:LINE Developersコンソールでチャンネルを準備する
- LINE Developersコンソールにログインし、プロバイダーの配下にOAチャンネルとBotチャンネルが揃っていることを確認する
- OAチャンネルの「Messaging API設定」タブからChannel SecretとChannel Access Token(長期)を発行する
- Botチャンネルでも同様にChannel SecretとChannel Access Tokenを発行する
- 両チャンネルの「Webhookの利用」をONにする。この時点ではまだWebhook URLは空でよい
- OAチャンネル側で「応答メッセージ」をOFFにする。ONのままだとWebhookとOAM側の自動応答が二重に反応し、ユーザーに2通届く
Step2:プロジェクトをセットアップする
mkdir line-multi-channel && cd line-multi-channel
npm init -y
npm install @line/bot-sdk dotenv express
.envには4つの値を入れます。公開リポジトリにコミットしないよう、.gitignoreへの追加をこの時点で済ませておきます。
OA_CHANNEL_SECRET=your_oa_channel_secret
OA_CHANNEL_ACCESS_TOKEN=your_oa_channel_access_token
BOT_CHANNEL_SECRET=your_bot_channel_secret
BOT_CHANNEL_ACCESS_TOKEN=your_bot_channel_access_token
PORT=3000
index.js:OA・Bot両対応と申請-承認フロー
申請内容は本来DBに保存すべきですが、ここでは最小構成としてメモリ上のMapに持たせます。本番で複数プロセスにする場合はSupabaseなど外部ストレージに置き換える必要があります。
require("dotenv").config();
const express = require("express");
const line = require("@line/bot-sdk");
const app = express();
const oaConfig = {
channelSecret: process.env.OA_CHANNEL_SECRET,
channelAccessToken: process.env.OA_CHANNEL_ACCESS_TOKEN,
};
const oaClient = new line.messagingApi.MessagingApiClient({
channelAccessToken: oaConfig.channelAccessToken,
});
const botConfig = {
channelSecret: process.env.BOT_CHANNEL_SECRET,
channelAccessToken: process.env.BOT_CHANNEL_ACCESS_TOKEN,
};
const botClient = new line.messagingApi.MessagingApiClient({
channelAccessToken: botConfig.channelAccessToken,
});
// 申請中の案件を一時保存するメモリストア(本番ではDBに置き換える)
const pendingRequests = new Map();
app.post(
"/webhook/oa",
line.middleware({ channelSecret: oaConfig.channelSecret }),
async (req, res) => {
res.sendStatus(200);
await Promise.all(req.body.events.map(handleOaEvent));
}
);
async function handleOaEvent(event) {
if (event.type !== "message" || event.message.type !== "text") return;
const userId = event.source.userId;
const text = event.message.text;
if (text.startsWith("申請:")) {
const content = text.replace("申請:", "").trim();
const requestId = `${userId}-${Date.now()}`;
pendingRequests.set(requestId, { userId, content });
await oaClient.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: "申請を受け付けました。承認をお待ちください。" }],
});
// 同一プロバイダー内なのでuserIdをそのまま使い、Botチャンネル経由で担当者に通知
await botClient.pushMessage({
to: process.env.APPROVER_GROUP_ID,
messages: [{
type: "text",
text: `申請ID:${requestId}\n内容:${content}\n承認する場合は「承認:${requestId}」と返信してください`,
}],
});
}
}
app.post(
"/webhook/bot",
line.middleware({ channelSecret: botConfig.channelSecret }),
async (req, res) => {
res.sendStatus(200);
await Promise.all(req.body.events.map(handleBotEvent));
}
);
async function handleBotEvent(event) {
if (event.type !== "message" || event.message.type !== "text") return;
const text = event.message.text;
if (!text.startsWith("承認:")) return;
const requestId = text.replace("承認:", "").trim();
const request = pendingRequests.get(requestId);
if (!request) {
await botClient.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: "該当する申請が見つかりません" }],
});
return;
}
// 申請者のuserIdはOAチャンネルで取得したもの。変換せずそのままOAチャンネルへ送信
await oaClient.pushMessage({
to: request.userId,
messages: [{ type: "text", text: `申請「${request.content}」が承認されました` }],
});
await botClient.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: "承認を処理し、申請者に通知しました" }],
});
pendingRequests.delete(requestId);
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`OA Webhook: http://localhost:${PORT}/webhook/oa`);
console.log(`Bot Webhook: http://localhost:${PORT}/webhook/bot`);
});
ngrokでのローカル疎通確認
- ターミナルを2つ開き、片方で
node index.jsを起動する - もう片方で
ngrok http 3000を実行し、発行されたhttpsのURLを控える - LINE DevelopersコンソールのOAチャンネルのWebhook URLに「ngrokのURL/webhook/oa」を設定する
- 同様にBotチャンネルには「ngrokのURL/webhook/bot」を設定する
- 各チャンネルの設定画面にある「検証」ボタンを押し、ステータスが200で返ることを確認する
- OAチャンネルに「申請:交通費精算」のように送信し、Botチャンネル側に通知が届くか確認する
ここでつまずきやすいのが、ngrokを再起動するたびにURLが変わる点です。無料プランでは固定URLが使えないため、再起動後は両チャンネルのWebhook URLを都度更新する必要があります。更新を忘れると、サーバーは動いているのにLINE側から何も届かない、という状態になります。
本番デプロイ時のWebhook URL切り替え
- 本番サーバー(Cloudflare WorkerやVPSなど)にデプロイし、常時稼働するhttpsのURLを用意する
- LINE DevelopersコンソールのWebhook URLを、ngrokのURLから本番URLに書き換える
- 本番の環境変数(OA_CHANNEL_SECRETなど)をホスティング先の環境変数設定に登録し、.envファイル自体はサーバーに置かない
- 本番URLに対しても「検証」ボタンでステータス200を確認してから、ngrok側のURL設定を消す
この切り替えを忘れたまま本番運用に入ると、ローカルのngrokプロセスを止めた瞬間にWebhookが沈黙する、という事故につながります。
詰まりやすい場所:署名検証エラー・環境変数・本番切り替え
実装時に実際につまずく場所は、だいたい決まっています。先に一覧にしておきます。
| 症状 | 原因 | 対処 |
|---|---|---|
| Webhook検証で400エラー | express.json()など別のbody parserをline.middlewareより前に挟んでいる | line.middlewareを使うルートではexpress.json()を適用しない |
| 署名は通るのに返信が401 | OAのイベントにBotのAccess Tokenでreplyしている | クライアントとconfigをチャンネルごとに分けて管理する |
| ngrok再起動後に無反応 | URLが変わったのにコンソール側のWebhook URLが古いまま | 再起動のたびに両チャンネルのURLを更新する |
| 本番デプロイ後に反応なし | Webhook URLがngrokのままで本番URLに切り替えていない | デプロイ後に必ずコンソールのURLを書き換えて検証する |
| ローカルでは動くが本番で401 | .envが本番サーバーに存在せず環境変数が未設定 | ホスティング先の環境変数設定にトークンを登録する |
特に一つ目のbody parserの問題は初見だと原因が分かりにくく、LINE側のエラーメッセージだけを見ても「署名が不正です」としか出ません。line.middlewareはraw bodyを使って署名検証を行うため、express.json()で先にbodyをパースしてしまうと検証に失敗します。該当ルートにはline.middlewareだけを適用し、他のルートでjsonパーサーを使う構成にすると解決します。
自社ではこう使っている:webhookと定時処理を組み合わせた運用
私の事務所では、案件収集・応募文作成・記事公開・計測までを含む定時タスクを複数本、ローカルPCの定時実行で回しています。この中でLINEのWebhookを、Cloudflare WorkerとSupabaseを組み合わせて使っています。
流れとしては、Webから収集した情報をAIが分類して下書きを作り、公開や送信の前段階でLINE経由の通知を人に送って、確認した上で実行する、という構成です。全自動で公開や送信までは進めず、人の確認を挟む設計にしているのは、誤送信や誤公開を防ぐためです。この記事で紹介したWebhook分離の設計は、まさにこの通知部分で実際に使っているものです。
複数チャンネルを使い分ける設計を先に固めておいたことで、収集系の通知と公開承認系の通知を別チャンネルに振り分けられ、どの通知がどの処理から来たのか追いやすくなっています。公開先の実績は制作実績で確認できます。
応用:3チャンネル以上にスケールする際の考え方
チャンネルが3つ以上になったら、configとclientとWebhookルートを1つずつ増やすのではなく、配列かマップで管理する設計に切り替えるのが合理的です。
const channels = [
{
key: "oa",
channelSecret: process.env.OA_CHANNEL_SECRET,
channelAccessToken: process.env.OA_CHANNEL_ACCESS_TOKEN,
},
{
key: "bot",
channelSecret: process.env.BOT_CHANNEL_SECRET,
channelAccessToken: process.env.BOT_CHANNEL_ACCESS_TOKEN,
},
{
key: "partner",
channelSecret: process.env.PARTNER_CHANNEL_SECRET,
channelAccessToken: process.env.PARTNER_CHANNEL_ACCESS_TOKEN,
},
];
const clients = new Map();
for (const ch of channels) {
clients.set(ch.key, new line.messagingApi.MessagingApiClient({
channelAccessToken: ch.channelAccessToken,
}));
app.post(
`/webhook/${ch.key}`,
line.middleware({ channelSecret: ch.channelSecret }),
async (req, res) => {
res.sendStatus(200);
const client = clients.get(ch.key);
await Promise.all(req.body.events.map((event) => handleEvent(ch.key, client, event)));
}
);
}
async function handleEvent(channelKey, client, event) {
if (event.type !== "message" || event.message.type !== "text") return;
// channelKeyで処理を分岐させる
}
この方式なら、チャンネルを追加するときはchannels配列に1件足すだけで、Webhookルートとクライアントが自動的に増えます。config・client・route・handlerの4点を個別に書き足していく方式と比べて、チャンネル数が増えてもコードの見通しが崩れません。ハンドラー内の処理分岐が複雑になってきたら、channelKeyごとに別ファイルへ切り出すのが次の整理の目安です。
補足
同一プロバイダー運用の価値は、ユーザーIDの変換という地味だが積み重なると重くなる作業を、最初から消せる点にあります。この記事で書いた申請から承認、通知までの流れも、変換レイヤーを一切持たずに動いています。
チャンネルを分けて使うこと自体は小さな設計判断ですが、運用が続くほど効いてきます。
手元の環境でまず試すなら、LINE Developersコンソールを開いて、使っているチャンネルが同じプロバイダーの配下にあるかを確認するところから始めてください。

LINEを承認窓口にしてAI自動化の確認作業をなくした仕組み
note自動投稿で記号が残る問題を正規表現の処理順序から直す方法
n8nとLINEとSupabaseでスマホ承認フローを最後まで動かす実装手順