Tutorial

@line/bot-sdk Express Middleware Example (v11, TypeScript)

A complete, type-checked Express + TypeScript webhook for @line/bot-sdk v11: where to mount middleware(), how validateSignature works in Node, Python and PHP, replyMessage with a Flex bubble, sentMessages and quote tokens, and the errors you'll actually hit.

LineBot.pro TeamUpdated 14 min read

@line/bot-sdk Express Middleware Example (v11, TypeScript)
Contents
  1. What the Express Middleware Does
  2. Install and Configure (Node.js 22+)
  3. Minimal Working Example
  4. How validateSignature Works (Node, Python, PHP)
  5. replyMessage(replyToken): Full Example
  6. ReplyMessageResponse.sentMessages and Quote Tokens
  7. Errors You Will Actually See
  8. Production Webhook Architecture
  9. Where LineBot.pro Fits
  10. Frequently Asked Questions

What the Express Middleware Does

middleware() from the official @line/bot-sdk package is an Express-compatible handler for your LINE webhook route. It reads the raw request body, checks the x-line-signature header against your channel secret (HMAC-SHA256) and, if the signature matches, parses the JSON into req.body, so your handler gets req.body.events. If the check fails, it passes an error to next(). It never answers the request itself.

Signature validation only works on the exact bytes LINE sent. If anything parses the body first, such as a global express.json(), the check fails. The code below targets the current SDK, version 11.3.0 as of October 2026, which requires Node.js 22 or later. Version 11 removed the legacy Client class, so older snippets with new Client(config) and client.replyMessage(token, message) no longer compile. This guide uses LineBotClient instead.

ExportWhat it does
middleware({ channelSecret })Reads the raw body, validates the signature, parses the JSON into req.body
LineBotClient.fromChannelAccessToken()Typed Messaging API client (reply, push, profile and more). messagingApi.MessagingApiClient is still exported too
webhook.CallbackRequest, webhook.EventTypes for the webhook payload and its events
validateSignature(body, secret, signature)The same check as a plain function, for Route Handlers and serverless functions
SignatureValidationFailed, JSONParseErrorThe errors the middleware passes to next()

Building the conversation itself? The LINE chatbot development tutorial covers conversation flows and AI replies. The LINE API integration tutorial covers LINE Login and calling the API without an SDK.

Install and Configure (Node.js 22+)

You need a Messaging API channel. Since September 2024 you can't create one directly in the LINE Developers Console. Create a LINE Official Account first, then enable the Messaging API for it in LINE Official Account Manager. Our LINE Official Account setup guide covers the account part. Then set up the project:

bash
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

From the channel in the LINE Developers Console, copy the Channel secret (Basic settings tab) and a channel access token (Messaging API tab). LINE recommends channel access tokens v2.1, which expire on a date you choose. A long-lived token is the quickest way to get started.

bash
# .env (never commit this file)
LINE_CHANNEL_SECRET=your-channel-secret
LINE_CHANNEL_ACCESS_TOKEN=your-channel-access-token
PORT=3000

Add a dev script to package.json:

json
{
  "scripts": {
    "dev": "tsx watch --env-file=.env src/index.ts"
  }
}

Minimal Working Example

This is the complete src/index.ts for an echo bot. It compiles against @line/bot-sdk 11.3.0 and Express 5 with strict TypeScript.

ts
// 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()

// The LINE middleware goes on the webhook route, before any JSON body parser.
app.post("/webhook", middleware({ channelSecret }), async (req, res) => {
  const { events } = req.body as webhook.CallbackRequest
  await Promise.all(events.map(handleEvent))
  res.sendStatus(200)
})

// Other routes can parse JSON as usual.
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))
}

// The middleware passes errors to next(); it doesn't send a 401 by itself.
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}`))

Four details make it work:

  1. The middleware sits on the webhook route, before any JSON parser. express.json() is registered after the route, so it never touches /webhook.
  2. res.sendStatus(200). LINE expects a 200 for every webhook request, including the request with an empty events array it sends to check the connection.
  3. The error handler. Without it, a bad signature ends as a generic 500 from Express. With it, you answer 401 or 400 and your logs show why.
  4. The replyToken check. In the v11 types replyToken is optional, so check it before replying.

To test it, expose port 3000 over HTTPS with a tunnel such as ngrok, enter https://<your-domain>/webhook as the Webhook URL in the Messaging API tab, click Verify, turn on Use webhook and send your account a message. LINE only accepts HTTPS webhook URLs whose certificate comes from a widely trusted certificate authority; self-signed certificates are rejected. Also turn off Greeting messages and Auto-reply messages in LINE Official Account Manager (both are on by default), or users get LINE's automatic reply as well as yours.

How validateSignature Works (Node, Python, PHP)

LINE signs every webhook request. It computes an HMAC-SHA256 of the request body with your channel secret and sends the Base64-encoded result in the x-line-signature header (Verify webhook signature). The SDK's validateSignature() recomputes the value and compares the two in constant time. In plain Node.js it comes down to this:

ts
import { createHmac, timingSafeEqual } from "node:crypto"

// What validateSignature() does, in a few lines
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)
}

When the check fails, the cause is almost always on LINE's list of common mistakes:

  • The body was parsed or re-serialized before the check (express.json() first, JSON.stringify(req.body), pretty-printing). Use the exact raw bytes.
  • A proxy, load balancer or framework changed the body or its encoding. Keep it UTF-8.
  • The secret belongs to another channel, or someone reissued the channel secret in the console.
  • The environment variable has a trailing newline or space.
  • The wrong algorithm, such as HMAC-SHA1 instead of HMAC-SHA256.

Don't replace the check with an IP allow-list. LINE doesn't publish the IP addresses its webhooks come from and recommends signature validation instead.

Without Express: Next.js Route Handlers and serverless functions

When there's no Express middleware chain, call validateSignature() yourself on the raw text of the request:

ts
// 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: verify before any 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")
}

Some platforms, such as Google Cloud Functions and Firebase, parse the body before your code runs. The middleware uses req.rawBody when it exists. If all you have is a parsed object, you can't validate the signature reliably, because re-serializing changes the bytes.

Python (line-bot-sdk v3)

The Python SDK (3.26.0, Python 3.10+) runs the same check inside WebhookHandler.handle(). Use the linebot.v3 modules; the older from linebot import LineBotApi, WebhookHandler API is deprecated.

python
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, not request.json
    try:
        handler.handle(body, signature)  # verifies the signature, then dispatches
    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}")],
            )
        )

For the check alone, SignatureValidator(channel_secret).validate(body, signature) from linebot.v3 returns True or False.

PHP (line-bot-sdk-php)

In the current PHP SDK (v12, PHP 8.2+), the method is static on LINE\Parser\SignatureValidator. It returns true or false and throws InvalidSignatureException when the signature is empty:

php
<?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; // empty signature header
}

if (!$isValid) {
    http_response_code(400);
    exit;
}
// Signature OK: decode $body and handle the events

Older releases had $bot->validateSignature($body, $signature) on the \LINE\LINEBot class, which is why many tutorials still show it. That class isn't part of current releases. To validate and parse the events in one call, use EventRequestParser::parseEventRequest($body, $channelSecret, $signature).

replyMessage(replyToken): Full Example

Every webhook event you can respond to carries a replyToken. The rules, from the Messaging API reference:

  • A reply token works once.
  • Use it within one minute of receiving the webhook. Later use isn't guaranteed, and LINE says the limit can change, so reply as soon as you can.
  • One reply carries up to 5 message objects.
  • Reply messages don't count toward your monthly message quota. Push, multicast, broadcast and narrowcast messages do.

A reply with a text message and a Flex Message bubble:

ts
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: "Thanks! Your booking is confirmed." },
      {
        type: "flex",
        altText: "Booking confirmed: Saturday 14:00",
        contents: {
          type: "bubble",
          body: {
            type: "box",
            layout: "vertical",
            contents: [
              { type: "text", text: "Booking confirmed", weight: "bold", size: "lg" },
              { type: "text", text: "Saturday, 14:00", color: "#666666", wrap: true },
            ],
          },
          footer: {
            type: "box",
            layout: "vertical",
            contents: [
              {
                type: "button",
                style: "primary",
                action: { type: "uri", label: "View booking", uri: "https://example.com/bookings/123" },
              },
            ],
          },
        },
      },
    ],
    notificationDisabled: false, // true = deliver without a push notification
  })

  return sentMessages
}

With notificationDisabled: true, the messages arrive without a push notification. If you need more than 5 messages, or have to answer after the minute is up, send the rest with pushMessage, which counts toward your quota. For slow work such as an AI call, client.showLoadingAnimation({ chatId: userId, loadingSeconds: 20 }) shows a loading animation in one-on-one chats for 5 to 60 seconds.

ReplyMessageResponse.sentMessages and Quote Tokens

replyMessage() resolves to a ReplyMessageResponse. Its sentMessages array holds one entry per message sent, in the same order as your messages array:

ts
type ReplyMessageResponse = {
  sentMessages: Array<{
    id: string          // ID of the sent message
    quoteToken?: string // only for message types that can be quoted
  }>
}

pushMessage() returns the same shape. A quoteToken is only included for messages that can be quoted: text (v1 and v2), sticker, image, video, template and Flex messages (for the last two, only the altText appears in the quote). Quote tokens don't expire and can be reused, but only in the chat where the message was sent (Get quote tokens).

To quote a message, set quoteToken on a text message. Incoming text messages carry their own quoteToken, so a bot can quote the customer's message now and its own confirmation later:

ts
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

  // Quote the customer's own message in the reply
  const { sentMessages } = await client.replyMessage({
    replyToken: event.replyToken,
    messages: [{ type: "text", text: "Order #1042 confirmed.", quoteToken: event.message.quoteToken }],
  })
  const orderQuoteToken = sentMessages[0]?.quoteToken // store it with the order

  // Later (e.g. when the order ships): a push message that quotes the confirmation
  await client.pushMessage(
    { to: userId, messages: [{ type: "text", text: "Your order has shipped.", quoteToken: orderQuoteToken }] },
    randomUUID(), // X-Line-Retry-Key: a retry with the same key is not sent twice
  )
}

The second argument of pushMessage() is the X-Line-Retry-Key. If a request with the same key has already been accepted, LINE answers 409 instead of sending the message again, so retrying after a timeout is safe.

Errors You Will Actually See

SymptomCauseFix
Every webhook gets 500, console Verify failsSignatureValidationFailed reached Express's default error handlerAdd the error handler from the minimal example, then fix the cause
401 from your error handlerBody parsed before middleware(), wrong or reissued channel secret, body changed by a proxyMount middleware() first on /webhook, check the secret, keep the raw body
400 from your error handlerJSONParseError: the body isn't valid JSONUsually a hand-made test request; real LINE webhooks are JSON
API 400 Invalid reply tokenToken already used, used too late, or from another channelReply once and fast; use push for late messages
API 400 The request body has X error(s)Invalid message objects: more than 5 messages, empty text, a wrong Flex property. details[].property names the fieldFix the field; test payloads with the "Validate message objects" endpoints
API 401Missing, expired or revoked channel access tokenIssue a new token
API 429Rate limit exceeded, or You have reached your monthly limit. (free or additional messages used up)Slow down; check your plan's quota
API 413Request larger than 2 MBSend fewer or smaller messages

Failed API calls throw an HTTPFetchError with status, statusText, headers and body:

ts
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) {
      // e.g. 400 {"message":"Invalid reply token"} or 429 {"message":"You have reached your monthly limit."}
      console.error("LINE API error", err.status, err.body)
      return
    }
    throw err
  }
}

One correction to advice found elsewhere: LINE does not retry failed webhooks by default. Webhook redelivery is an opt-in setting in the Messaging API tab. When it's on, LINE resends webhooks that didn't get a 2xx response, a number of times it doesn't disclose (Receive messages).

Production Webhook Architecture

Diagram
User sends a message
      │
      ▼
LINE Platform ── HTTPS POST + x-line-signature ──▶ POST /webhook (your server)
                                                    │ middleware(): raw body → HMAC check → JSON
                                                    │ 200 OK, fast
                                                    ▼
                                              handleEvent(event)
                                                    │ your logic, database, AI…
                                                    ▼
LINE Platform ◀── replyMessage / pushMessage ───────┘
      │
      ▼
User sees the reply

LINE recommends processing events asynchronously, so one slow request doesn't hold up the next. A common pattern answers 200 first, then handles the events in the background and skips duplicates by webhookEventId:

ts
const seen = new Set<string>() // use Redis or your database in production

app.post("/webhook", middleware({ channelSecret }), (req, res) => {
  const { events } = req.body as webhook.CallbackRequest
  res.sendStatus(200) // acknowledge first: LINE only needs a 200

  for (const event of events) {
    if (seen.has(event.webhookEventId)) continue // duplicate or redelivered event
    seen.add(event.webhookEventId)
    handleEvent(event).catch((err) => console.error("event failed", event.webhookEventId, err))
  }
})

Keep these points in mind:

  • Duplicates happen. LINE can deliver the same event more than once, for example after a network problem or a redelivery. The webhookEventId stays the same, and deliveryContext.isRedelivery tells you whether the event was resent. Once you run more than one instance, store processed IDs in Redis or your database instead of memory.
  • Answering first has a cost. After a 200, LINE won't redeliver the event, so log failures and retry them yourself.
  • Serverless platforms can freeze after the response. On Vercel, AWS Lambda and similar platforms, work that runs after the response may never finish. Either finish before you respond (still within the reply token's minute), or hand the work to an after-response API such as Next.js after() or to a queue.
  • One webhook URL per channel. A Messaging API channel sends all its events to a single URL, so route by event.type inside one service instead of pointing several services at the same channel.
  • State lives on your side. LINE doesn't keep conversation state for you; store it against the user ID. For navigation, a rich menu stays visible under the chat.

Where LineBot.pro Fits

LineBot.pro isn't an SDK, an API or a hosting platform, and it has no public API. Two parts of it are still useful when you build with the code above:

  • Message and menu design. The AI generators create a Rich Message (one Flex bubble with a hero image, text and a button) or a rich menu (image plus JSON, 1 to 6 buttons). Copy the JSON, or download it with the images, then send it with replyMessage or pushMessage, or upload the menu yourself. A Rich Message costs 8 credits and a rich menu 10. New accounts get 50 free credits, and more come in one-time packs with no subscription (pricing). Try the Rich Message generator.
  • No code at all. If you only need keyword replies, the LINE keyword bot answers one-on-one chats with approved Rich Message cards. Optional AI routing handles messages that match no keyword: the AI picks a card, it never writes the answer. The bot works by pasting LineBot.pro's webhook URL into your channel, so it can't run alongside your own webhook server on the same channel.

For integrations with your booking system, CRM or a LIFF app, we take on custom LINE development projects.

Frequently Asked Questions

Q: Where do I put the @line/bot-sdk middleware in Express? On the webhook route, before any body parser: app.post("/webhook", middleware({ channelSecret }), handler). Register express.json() after that route, or only on the routes that need it. If a global parser runs first, the raw body is gone and every request fails with SignatureValidationFailed.

Q: Why does my LINE webhook return 500 instead of 401? The middleware doesn't send a response when validation fails; it calls next(err). Without an error handler, Express turns that into a 500. Add a handler that answers 401 for SignatureValidationFailed and 400 for JSONParseError.

Q: How do I validate the signature with line-bot-sdk-php? Call LINE\Parser\SignatureValidator::validateSignature($body, $channelSecret, $signature) with the raw body from php://input. It returns a boolean and throws InvalidSignatureException if the signature is empty. The $bot->validateSignature() method on \LINE\LINEBot belongs to older SDK releases.

Q: How do I validate the signature in Python? With line-bot-sdk v3, WebhookHandler(channel_secret).handle(body, signature) validates and dispatches, and raises InvalidSignatureError on a mismatch. For the check alone, use SignatureValidator(channel_secret).validate(body, signature) from linebot.v3. Pass the raw body, which is request.get_data(as_text=True) in Flask.

Q: What does ReplyMessageResponse.sentMessages contain? One object per sent message, in request order, with the message id and, for message types that can be quoted, a quoteToken. Keep the quote token if a later message should quote that one.

Q: Can I use replyMessage and pushMessage together? Yes. Reply first with the reply token (free, within about a minute, up to 5 messages), then send anything later with pushMessage. Push messages count toward your plan's monthly quota, one message per recipient.


Next steps:

Try LineBot.pro on your LINE OA

Make rich menus, rich messages and banners at LINE's exact sizes, send broadcasts and set up keyword auto-replies.

Start free with 50 credits

No card needed. Credits never expire.

Rather have an expert check your account? LINE OA audit, ฿2,500

About LineBot.pro

LineBot.pro is an AI tool for LINE Official Accounts: it creates Rich Menus, Rich Messages and banners, sends broadcast campaigns and runs keyword auto-replies. Our team writes these guides based on hands-on work with the LINE Messaging API.