Express 中介軟體做了什麼
官方套件 @line/bot-sdk 的 middleware() 是一個相容 Express 的處理器,掛在接收 LINE Webhook 的路由上。它會讀取未經處理的請求內容(raw body),用你的 Channel secret 驗證 x-line-signature 標頭(HMAC-SHA256),簽章相符時再把 JSON 解析進 req.body,你的處理器就能直接使用 req.body.events。驗證失敗時,它只會把錯誤交給 next(),自己不會回應請求。
簽章驗證只對 LINE 原本送出的位元組有效。只要有東西先解析了請求內容,例如全域設定的 express.json(),驗證就會失敗。本文程式碼以 2026 年 10 月的最新版 SDK 11.3.0 為準,需要 Node.js 22 以上。第 11 版移除了舊的 Client 類別,所以使用 new Client(config) 和 client.replyMessage(token, message) 的舊範例已經無法編譯,本文改用 LineBotClient。
| 匯出項目 | 用途 |
|---|---|
middleware({ channelSecret }) | 讀取 raw body、驗證簽章,並把 JSON 解析進 req.body |
LineBotClient.fromChannelAccessToken() | 具型別的 Messaging API 用戶端(回覆、推播、取得個人資料等)。messagingApi.MessagingApiClient 也仍然有匯出 |
webhook.CallbackRequest、webhook.Event | Webhook 內容與各種事件的型別 |
validateSignature(body, secret, signature) | 以函式形式做同樣的驗證,適用於 Route Handler 與無伺服器環境 |
SignatureValidationFailed、JSONParseError | 中介軟體交給 next() 的錯誤 |
要開發對話本身,可以參考說明對話流程與 AI 回覆的 LINE Bot 開發教學;LINE Login 與不透過 SDK 呼叫 API 的做法,請看 LINE API 串接教學。
安裝與設定(Node.js 22 以上)
你需要一個 Messaging API 頻道。自 2024 年 9 月起,已經無法在 LINE Developers Console 直接建立這種頻道:請先建立 LINE 官方帳號,再到 LINE Official Account Manager 為它啟用 Messaging API(建立帳號的步驟可參考 LINE 官方帳號申請教學)。接著準備專案:
mkdir line-webhook && cd line-webhook
npm init -y
npm pkg set type=module
npm install express @line/bot-sdk
npm install -D typescript tsx @types/express @types/node
在 LINE Developers Console 打開頻道,複製 Channel secret(Basic settings 分頁)與一組 Channel access token(Messaging API 分頁)。LINE 建議使用可自訂有效期限的 Channel access token v2.1;只想先跑起來的話,用長期(long-lived)token 最快。
# .env(不要把這個檔案提交到版本控制)
LINE_CHANNEL_SECRET=your-channel-secret
LINE_CHANNEL_ACCESS_TOKEN=your-channel-access-token
PORT=3000
在 package.json 加入 dev 指令:
{
"scripts": {
"dev": "tsx watch --env-file=.env src/index.ts"
}
}
最小可執行範例
以下是一個回聲機器人(把收到的文字原樣回傳)的完整 src/index.ts,已確認可在 @line/bot-sdk 11.3.0、Express 5 與 TypeScript strict 模式下編譯。
// src/index.ts
import express from "express"
import {
JSONParseError,
LineBotClient,
SignatureValidationFailed,
middleware,
webhook,
} from "@line/bot-sdk"
const channelSecret = process.env.LINE_CHANNEL_SECRET
const channelAccessToken = process.env.LINE_CHANNEL_ACCESS_TOKEN
if (!channelSecret || !channelAccessToken) {
throw new Error("Set LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN")
}
const client = LineBotClient.fromChannelAccessToken({ channelAccessToken })
const app = express()
// LINE 中介軟體掛在 Webhook 路由上,而且要在任何 JSON 解析器之前
app.post("/webhook", middleware({ channelSecret }), async (req, res) => {
const { events } = req.body as webhook.CallbackRequest
await Promise.all(events.map(handleEvent))
res.sendStatus(200)
})
// 其他路由照常解析 JSON
app.use(express.json())
async function handleEvent(event: webhook.Event) {
if (event.type !== "message" || event.message.type !== "text" || !event.replyToken) return
const { sentMessages } = await client.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: `Echo: ${event.message.text}` }],
})
console.log("sent message IDs:", sentMessages.map((m) => m.id))
}
// 中介軟體只會把錯誤交給 next(),不會自己回 401
app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof SignatureValidationFailed) {
res.status(401).send("Invalid signature")
return
}
if (err instanceof JSONParseError) {
res.status(400).send("Invalid JSON")
return
}
next(err)
})
const port = Number(process.env.PORT ?? 3000)
app.listen(port, () => console.log(`LINE bot listening on :${port}`))
讓它正常運作的關鍵有四個:
- 中介軟體掛在 Webhook 路由上,排在任何 JSON 解析器之前。
express.json()是在這個路由之後才註冊的,所以不會碰到/webhook。 res.sendStatus(200)。 LINE 要求每一次 Webhook 請求都收到 200,包括為了確認連線而送來、events為空陣列的請求。- 錯誤處理器。 沒有它的話,簽章錯誤只會變成 Express 預設的 500;有了它,你可以回 401 或 400,記錄檔也看得出原因。
- 檢查
replyToken。 在 v11 的型別中replyToken是選填的,回覆前要先確認。
測試時,用 ngrok 之類的通道工具以 HTTPS 開放 3000 埠,在 Messaging API 分頁的 Webhook URL 填入 https://<你的網域>/webhook,按 Verify,開啟 Use webhook,再傳訊息給你的帳號。LINE 只接受 HTTPS 的 Webhook URL,而且憑證必須由一般瀏覽器信任的憑證機構簽發,自簽憑證不能用。另外請在 LINE Official Account Manager 關閉加入好友的歡迎訊息與自動回應訊息(兩者預設都是開啟),否則使用者會同時收到 LINE 的自動回應和你的機器人回覆。
validateSignature 的運作方式(Node.js、Python、PHP)
LINE 會替每一次 Webhook 請求簽章:以你的 Channel secret 為金鑰,計算請求內容的 HMAC-SHA256,再把 Base64 編碼後的結果放在 x-line-signature 標頭中(Verify webhook signature)。SDK 的 validateSignature() 會重新計算這個值,並以固定時間的方式比對兩者。用純 Node.js 寫,大致如下:
import { createHmac, timingSafeEqual } from "node:crypto"
// 用幾行程式碼說明 validateSignature() 做的事
export function isValidSignature(body: string | Buffer, channelSecret: string, signature: string): boolean {
const expected = createHmac("sha256", channelSecret).update(body).digest()
const received = Buffer.from(signature, "base64")
return expected.length === received.length && timingSafeEqual(expected, received)
}
驗證失敗時,原因幾乎都在 LINE 文件列出的常見錯誤裡:
- 驗證前就解析或重新序列化了請求內容(
express.json()先執行、JSON.stringify(req.body)、重新排版 JSON)。請使用原始的位元組。 - 代理伺服器、負載平衡器或框架改動了內容或編碼。請維持 UTF-8。
- 用了別的頻道的 secret,或有人在 Console 重新發行了 Channel secret。
- 環境變數結尾多了換行或空白。
- 用錯演算法,例如用 HMAC-SHA1 取代 HMAC-SHA256。
不要用 IP 白名單取代簽章驗證。LINE 不公開 Webhook 的來源 IP,並建議改用簽章驗證。
不用 Express:Next.js Route Handler 與無伺服器環境
沒有 Express 中介軟體鏈的環境,就自己對請求的原始文字呼叫 validateSignature():
// app/api/line/webhook/route.ts (Next.js App Router)
import { LineBotClient, validateSignature, webhook } from "@line/bot-sdk"
const channelSecret = process.env.LINE_CHANNEL_SECRET ?? ""
const client = LineBotClient.fromChannelAccessToken({
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN ?? "",
})
export async function POST(request: Request) {
const body = await request.text() // raw body:先驗證,再 JSON.parse
const signature = request.headers.get("x-line-signature") ?? ""
if (!validateSignature(body, channelSecret, signature)) {
return new Response("Invalid signature", { status: 401 })
}
const { events } = JSON.parse(body) as webhook.CallbackRequest
await Promise.all(
events.map(async (event) => {
if (event.type !== "message" || event.message.type !== "text" || !event.replyToken) return
await client.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: `Echo: ${event.message.text}` }],
})
}),
)
return new Response("OK")
}
有些平台(例如 Google Cloud Functions、Firebase)會在你的程式執行前先解析請求內容。中介軟體會優先使用 req.rawBody;如果你手上只有解析過的物件,重新序列化會改變位元組,就無法可靠地驗證簽章。
Python(line-bot-sdk v3)
Python 版 SDK(3.26.0,Python 3.10 以上)在 WebhookHandler.handle() 裡做同樣的驗證。請使用 linebot.v3 模組;舊的 from linebot import LineBotApi, WebhookHandler 已不建議使用。
import os
from flask import Flask, abort, request
from linebot.v3 import WebhookHandler
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.messaging import (
ApiClient,
Configuration,
MessagingApi,
ReplyMessageRequest,
TextMessage,
)
from linebot.v3.webhooks import MessageEvent, TextMessageContent
app = Flask(__name__)
configuration = Configuration(access_token=os.environ["LINE_CHANNEL_ACCESS_TOKEN"])
handler = WebhookHandler(os.environ["LINE_CHANNEL_SECRET"])
@app.post("/webhook")
def webhook():
signature = request.headers.get("X-Line-Signature", "")
body = request.get_data(as_text=True) # 使用 raw body,不要用 request.json
try:
handler.handle(body, signature) # 先驗證簽章,再分派給處理函式
except InvalidSignatureError:
abort(400)
return "OK"
@handler.add(MessageEvent, message=TextMessageContent)
def handle_text(event):
with ApiClient(configuration) as api_client:
MessagingApi(api_client).reply_message(
ReplyMessageRequest(
reply_token=event.reply_token,
messages=[TextMessage(text=f"Echo: {event.message.text}")],
)
)
只想做驗證的話,linebot.v3 的 SignatureValidator(channel_secret).validate(body, signature) 會回傳 True 或 False。
PHP(line-bot-sdk-php)
目前的 PHP 版 SDK(v12,PHP 8.2 以上)把這個方法放在 LINE\Parser\SignatureValidator 的靜態方法。它回傳 true 或 false,簽章為空時會丟出 InvalidSignatureException:
<?php
require __DIR__ . '/vendor/autoload.php';
use LINE\Parser\Exception\InvalidSignatureException;
use LINE\Parser\SignatureValidator;
$body = file_get_contents('php://input'); // raw request body
$signature = $_SERVER['HTTP_X_LINE_SIGNATURE'] ?? '';
$channelSecret = (string) getenv('LINE_CHANNEL_SECRET');
try {
$isValid = SignatureValidator::validateSignature($body, $channelSecret, $signature);
} catch (InvalidSignatureException $e) {
$isValid = false; // 沒有簽章標頭
}
if (!$isValid) {
http_response_code(400);
exit;
}
// 簽章正確:解碼 $body 並處理事件
舊版 SDK 在 \LINE\LINEBot 類別上提供 $bot->validateSignature($body, $signature),所以很多教學還是這樣寫,但目前的版本已經沒有這個類別。想一次完成驗證與事件解析,可以用 EventRequestParser::parseEventRequest($body, $channelSecret, $signature)。
replyMessage(replyToken) 完整範例
可以回覆的 Webhook 事件都會帶有 replyToken。依照 Messaging API reference,規則如下:
- reply token 只能用一次。
- 必須在收到 Webhook 後一分鐘內使用。超過時間不保證有效,而且 LINE 表示這個時限可能變更,所以請盡快回覆。
- 一次回覆最多 5 個訊息物件。
- 回覆訊息不計入每月的訊息則數;推播(push)、multicast、broadcast 與 narrowcast 則會計入。
以下範例回覆一則文字訊息和一個 Flex Message 泡泡(bubble):
import { LineBotClient, webhook } from "@line/bot-sdk"
const client = LineBotClient.fromChannelAccessToken({ channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN ?? "" })
export async function confirmBooking(event: webhook.MessageEvent) {
if (!event.replyToken) return
const { sentMessages } = await client.replyMessage({
replyToken: event.replyToken,
messages: [
{ type: "text", text: "謝謝!您的預約已確認。" },
{
type: "flex",
altText: "預約已確認:星期六 14:00",
contents: {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [
{ type: "text", text: "預約已確認", weight: "bold", size: "lg" },
{ type: "text", text: "星期六 14:00", color: "#666666", wrap: true },
],
},
footer: {
type: "box",
layout: "vertical",
contents: [
{
type: "button",
style: "primary",
action: { type: "uri", label: "查看預約", uri: "https://example.com/bookings/123" },
},
],
},
},
},
],
notificationDisabled: false, // true = 傳送時不發推播通知
})
return sentMessages
}
設定 notificationDisabled: true 時,訊息送達時不會跳出推播通知。需要超過 5 則訊息,或超過一分鐘才能回覆時,請用 pushMessage 傳送其餘訊息(會計入則數)。像呼叫 AI 這類耗時的處理,可以用 client.showLoadingAnimation({ chatId: userId, loadingSeconds: 20 }) 在一對一聊天中顯示 5 到 60 秒的載入動畫。
ReplyMessageResponse.sentMessages 與引用 token
replyMessage() 會回傳 ReplyMessageResponse,其中的 sentMessages 陣列對應每一則送出的訊息,順序與你的 messages 陣列相同:
type ReplyMessageResponse = {
sentMessages: Array<{
id: string // 已送出訊息的 ID
quoteToken?: string // 只有可以被引用的訊息類型才有
}>
}
pushMessage() 回傳的格式相同。只有可以被引用的訊息才會帶 quoteToken(引用 token):文字(v1 與 v2)、貼圖、圖片、影片、樣板訊息與 Flex Message(後兩者被引用時只會顯示 altText)。引用 token 沒有有效期限,可以重複使用,但只能用在送出該訊息的聊天室裡(Get quote tokens)。
要引用訊息,就在文字訊息上設定 quoteToken。使用者傳來的文字訊息也有自己的 quoteToken,所以機器人可以現在引用顧客的訊息,之後再引用自己的確認訊息:
import { randomUUID } from "node:crypto"
import { LineBotClient, webhook } from "@line/bot-sdk"
const client = LineBotClient.fromChannelAccessToken({ channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN ?? "" })
export async function confirmOrder(event: webhook.MessageEvent, userId: string) {
if (event.message.type !== "text" || !event.replyToken) return
// 在回覆中引用顧客的訊息
const { sentMessages } = await client.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: "訂單 #1042 已確認。", quoteToken: event.message.quoteToken }],
})
const orderQuoteToken = sentMessages[0]?.quoteToken // 和訂單資料一起儲存
// 之後(例如出貨時):用推播訊息引用這則確認訊息
await client.pushMessage(
{ to: userId, messages: [{ type: "text", text: "您的訂單已出貨。", quoteToken: orderQuoteToken }] },
randomUUID(), // X-Line-Retry-Key:用同一個 key 重試不會重複傳送
)
}
pushMessage() 的第二個參數是 X-Line-Retry-Key。如果相同 key 的請求已經被接受,LINE 會回 409 而不會重複傳送,所以逾時後重試是安全的。
實際會遇到的錯誤
| 症狀 | 原因 | 解法 |
|---|---|---|
| 每次 Webhook 都回 500,Console 的 Verify 也失敗 | SignatureValidationFailed 落到 Express 預設的錯誤處理器 | 加上最小範例中的錯誤處理器,再修正根本原因 |
| 你的錯誤處理器回 401 | middleware() 之前內容已被解析、Channel secret 錯誤或被重新發行、代理伺服器改動了內容 | 在 /webhook 上最先執行 middleware()、檢查 secret、保留 raw body |
| 你的錯誤處理器回 400 | JSONParseError:內容不是有效的 JSON | 多半是自己手動送的測試請求;LINE 真正的 Webhook 一定是 JSON |
API 回 400 Invalid reply token | token 已用過、太晚使用,或屬於其他頻道 | 只回覆一次而且要快;晚送的訊息改用推播 |
API 回 400 The request body has X error(s) | 訊息物件無效:超過 5 則、空白文字、Flex 屬性錯誤等,details[].property 會指出哪個欄位 | 修正該欄位;可用「Validate message objects」系列端點事先檢查 |
| API 回 401 | 沒有 Channel access token,或 token 已過期、被撤銷 | 重新發行 token |
| API 回 429 | 超過速率限制,或 You have reached your monthly limit.(免費或加購訊息額度用完) | 放慢速度,並確認方案的訊息則數 |
| API 回 413 | 請求超過 2 MB | 減少訊息數量或縮小內容 |
API 呼叫失敗時,SDK 會丟出帶有 status、statusText、headers 和 body 的 HTTPFetchError:
import { HTTPFetchError, LineBotClient } from "@line/bot-sdk"
const client = LineBotClient.fromChannelAccessToken({ channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN ?? "" })
export async function safeReply(replyToken: string, text: string) {
try {
await client.replyMessage({ replyToken, messages: [{ type: "text", text }] })
} catch (err) {
if (err instanceof HTTPFetchError) {
// 例如 400 {"message":"Invalid reply token"} 或 429 {"message":"You have reached your monthly limit."}
console.error("LINE API error", err.status, err.body)
return
}
throw err
}
}
這裡要更正一個常見的說法:LINE 預設不會重送失敗的 Webhook。**Webhook redelivery(重新傳送)**是 Messaging API 分頁中需要自行開啟的選項;開啟後,沒有收到 2xx 回應的 Webhook 會被重新傳送,次數與間隔 LINE 並未公開(Receive messages)。
正式環境的 Webhook 架構
使用者傳送訊息
│
▼
LINE 平台 ── HTTPS POST + x-line-signature ──▶ POST /webhook(你的伺服器)
│ middleware(): raw body → 驗證 HMAC → JSON
│ 立即回 200 OK
▼
handleEvent(event)
│ 你的商業邏輯、資料庫、AI
▼
LINE 平台 ◀── replyMessage / pushMessage ──────┘
│
▼
使用者看到回覆LINE 建議以非同步方式處理事件,避免一個慢請求拖住後面的事件。常見做法是先回 200,再於背景處理事件,並用 webhookEventId 略過重複事件:
const seen = new Set<string>() // 正式環境請存在 Redis 或資料庫
app.post("/webhook", middleware({ channelSecret }), (req, res) => {
const { events } = req.body as webhook.CallbackRequest
res.sendStatus(200) // 先回應:LINE 只需要 200
for (const event of events) {
if (seen.has(event.webhookEventId)) continue // 重複或重新傳送的事件
seen.add(event.webhookEventId)
handleEvent(event).catch((err) => console.error("event failed", event.webhookEventId, err))
}
})
請留意:
- 重複事件會發生。 網路問題或重新傳送都可能讓同一事件送達不只一次。
webhookEventId不會變,deliveryContext.isRedelivery則告訴你是否為重送。執行多個實例時,請把處理過的 ID 存在 Redis 或資料庫,而不是記憶體。 - 先回應也有代價。 回了 200 之後,LINE 不會再重送這個事件,所以失敗要自己記錄並重試。
- 無伺服器平台可能在回應後凍結。 在 Vercel、AWS Lambda 等平台上,回應之後才執行的工作可能做不完。請在回應前完成處理(仍要在 reply token 的一分鐘內),或交給回應後執行的 API(例如 Next.js 的
after())或佇列。 - 一個頻道只有一個 Webhook URL。 Messaging API 頻道會把所有事件送到同一個 URL,所以請在同一個服務裡依
event.type分流,而不是讓多個服務共用同一個頻道。 - 狀態由你保存。 LINE 不會替你保存對話狀態,請以使用者 ID 為鍵自行儲存。需要隨時可見的導覽時,請用圖文選單。
LineBot.pro 能派上用場的地方
LineBot.pro 不是 SDK、不是 API,也不是代管平台,而且沒有公開 API。不過在用上面的程式碼開發時,有兩個部分用得上:
- 設計訊息與選單。 AI 產生器可以做出圖文訊息(一個含主圖、文字和按鈕的 Flex 泡泡)或圖文選單(圖片加 JSON,1 到 6 個按鈕)。你可以複製 JSON,或連同圖片一起下載,再用
replyMessage、pushMessage傳送,或自行上傳選單。圖文訊息每次 8 點、圖文選單 10 點;新註冊送 50 點,之後可單次購買點數包,沒有月費(價格)。試用圖文訊息產生器 - 完全不寫程式。 如果只需要依關鍵字回覆,LINE 關鍵字回覆機器人會用你核准過的圖文訊息卡片回覆一對一聊天。沒有對應關鍵字的訊息,可以選擇開啟 AI 分流來挑選卡片:AI 只負責選卡片,不會自己寫回答。這個機器人的運作方式是把 LineBot.pro 的 Webhook URL 填進你的頻道,所以無法和你自己的 Webhook 伺服器在同一個頻道並用。
若要串接預約系統、CRM 或 LIFF 應用程式,我們也承接 LINE 客製化開發。
常見問題
Q: 在 Express 裡,@line/bot-sdk 的中介軟體要放在哪裡?
放在 Webhook 路由上,而且要在任何內容解析器之前:app.post("/webhook", middleware({ channelSecret }), handler)。express.json() 請在這個路由之後再註冊,或只設定在需要的路由。全域解析器先執行的話,raw body 就沒了,每個請求都會以 SignatureValidationFailed 失敗。
Q: 為什麼我的 LINE Webhook 回 500 而不是 401?
中介軟體在驗證失敗時不會自己回應,而是呼叫 next(err)。沒有錯誤處理器時,Express 會把它變成 500。請加上一個處理器:遇到 SignatureValidationFailed 回 401,遇到 JSONParseError 回 400。
Q: 用 line-bot-sdk-php 要怎麼驗證簽章?
用從 php://input 讀到的 raw body 呼叫 LINE\Parser\SignatureValidator::validateSignature($body, $channelSecret, $signature)。它回傳布林值,簽章為空時丟出 InvalidSignatureException。\LINE\LINEBot 類別上的 $bot->validateSignature() 是舊版 SDK 的方法。
Q: 在 Python 要怎麼驗證簽章?
line-bot-sdk v3 中,WebhookHandler(channel_secret).handle(body, signature) 會驗證並分派事件,不相符時丟出 InvalidSignatureError。只想驗證的話,用 linebot.v3 的 SignatureValidator(channel_secret).validate(body, signature)。傳入的必須是 raw body,在 Flask 中是 request.get_data(as_text=True)。
Q: ReplyMessageResponse.sentMessages 裡有什麼?
每則送出的訊息對應一個物件,順序與請求相同,內含訊息 id;可以被引用的訊息類型還會有 quoteToken。如果之後的訊息要引用它,請把引用 token 存起來。
Q: replyMessage 和 pushMessage 可以一起用嗎?
可以。先用 reply token 回覆(免費、約一分鐘內、最多 5 則),之後的訊息再用 pushMessage 傳送。推播訊息會計入方案的每月則數,每位收件者算 1 則。
下一步:
- 完成整個機器人:LINE Bot 開發教學(Node.js、Python)
- 不用 SDK 呼叫 API 並加入 LINE Login:LINE API 串接教學
- 看看開發者需要知道的變更:LINE Messaging API 2026 更新
- 想交給專人開發:LINE 客製化開發
- 用 AI 設計 Flex Message:圖文訊息產生器



