Tutorial

@line/bot-sdk Express Middleware: Complete Example with replyMessage & validateSignature

Working code example for the @line/bot-sdk Express middleware: webhook signature validation, replyMessage with ReplyMessageResponse.sentMessages, error handling, and TypeScript types. Production-ready.

LineBot.pro Team11 min read
@line/bot-sdk Express Middleware: Complete Example with replyMessage & validateSignature

#Why an Express Middleware?

The official @line/bot-sdk ships an Express-compatible middleware that does two things you would otherwise re-implement by hand:

  1. Verifies the webhook signature (x-line-signature header) using HMAC-SHA256 against your channel secret
  2. Parses the JSON body and rejects requests with an invalid signature with HTTP 401

Without it, you have to read the raw body (Express's default express.json() consumes it before HMAC can run), recompute the signature, and time-constant-compare. That's a common source of bugs and a common interview question. The middleware does it correctly in ~3 lines of setup.

This guide shows the complete, idiomatic Express + TypeScript example β€” the one most developers Google for and rarely find in one place.

Already building? Pair this with our LINE chatbot development tutorial for the conversation-flow side, and the LINE API integration guide for OAuth-style flows.

#Installation & Project Setup

bash
npm init -y
npm i express @line/bot-sdk
npm i -D typescript @types/express @types/node ts-node-dev
npx tsc --init

Set two environment variables β€” get them from the LINE Developers Console:

bash
# .env
LINE_CHANNEL_ACCESS_TOKEN=...
LINE_CHANNEL_SECRET=...

Add a dev script to package.json:

json
{
  "scripts": {
    "dev": "ts-node-dev --respawn src/index.ts"
  }
}

#Minimal Working Example

ts
// src/index.ts
import express, { Request, Response } from "express"
import { middleware, messagingApi, WebhookEvent } from "@line/bot-sdk"

const config = {
  channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
  channelSecret: process.env.LINE_CHANNEL_SECRET!,
}

const client = new messagingApi.MessagingApiClient({
  channelAccessToken: config.channelAccessToken,
})

const app = express()

// IMPORTANT: do NOT call express.json() before middleware().
// The middleware needs the raw body to verify the HMAC signature.
app.post("/webhook", middleware(config), async (req: Request, res: Response) => {
  const events: WebhookEvent[] = req.body.events
  await Promise.all(events.map(handleEvent))
  res.status(200).end()
})

async function handleEvent(event: WebhookEvent) {
  if (event.type !== "message" || event.message.type !== "text") return

  const reply = await client.replyMessage({
    replyToken: event.replyToken,
    messages: [{ type: "text", text: `Echo: ${event.message.text}` }],
  })

  // ReplyMessageResponse.sentMessages contains the IDs of every message LINE accepted
  console.log("sent message ids:", reply.sentMessages.map(m => m.id))
}

app.listen(3000, () => console.log("LINE bot running on :3000"))

That's the full file. Three import names matter:

  • middleware β€” Express-style request handler that validates and parses
  • messagingApi.MessagingApiClient β€” the modern (post-2024) typed API client
  • WebhookEvent β€” discriminated union of every webhook event type

LINE Bot webhook architecture: app β†’ Express middleware β†’ client.replyMessage
LINE Bot webhook architecture: app β†’ Express middleware β†’ client.replyMessage

#How validateSignature() Works

The Express middleware internally calls validateSignature(body, channelSecret, signature). Here is what it does in pseudo-code so you understand failures:

ts
import crypto from "node:crypto"

function validateSignature(body: Buffer | string, secret: string, signature: string) {
  const computed = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("base64")
  // timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(computed),
    Buffer.from(signature),
  )
}

If the headers, body bytes, or secret are wrong, the middleware short-circuits with HTTP 401 and a SignatureValidationFailed error. You will see this if:

  • You put express.json() before middleware(config) β€” the body is mutated before HMAC runs
  • Behind a reverse proxy that rewrites Content-Length or charset
  • The channelSecret env var has a trailing newline (common with shell heredocs)

Direct call: if you need validateSignature outside Express (e.g. in a Next.js Route Handler or AWS Lambda), import it via import { validateSignature } from "@line/bot-sdk" and pass the raw body as Buffer.

#replyMessage(replyToken, ...) β€” Full Example

The Messaging API requires you to use replyToken from the webhook event within 30 seconds. Each token is single-use.

ts
import { messagingApi } from "@line/bot-sdk"

const client = new messagingApi.MessagingApiClient({
  channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
})

await client.replyMessage({
  replyToken: event.replyToken,
  messages: [
    { type: "text", text: "Hello!" },
    {
      type: "flex",
      altText: "Booking confirmed",
      contents: {
        type: "bubble",
        body: {
          type: "box",
          layout: "vertical",
          contents: [
            { type: "text", text: "Booking confirmed", weight: "bold", size: "lg" },
            { type: "text", text: "You will receive a reminder 1 day before." },
          ],
        },
      },
    },
  ],
  notificationDisabled: false, // mute push notification when true
})

A single replyMessage call accepts up to 5 messages in the array. Going over returns HTTP 400.

#ReplyMessageResponse.sentMessages

Since SDK v9 (2024), replyMessage returns a typed ReplyMessageResponse containing a sentMessages array β€” one entry per message LINE actually accepted, in the same order:

ts
type ReplyMessageResponse = {
  sentMessages: Array<{
    id: string             // unique message ID, useful for analytics
    quoteToken?: string    // pass to a future sendMessage call to "quote" this one
  }>
}

Use cases:

  • Analytics: store id to correlate sent messages with later message-id analytics endpoints
  • Quote replies: keep quoteToken to make the next bot reply a quoted thread
ts
const { sentMessages } = await client.replyMessage({ replyToken, messages })
const lastQuoteToken = sentMessages.at(-1)?.quoteToken

#Error Handling & 401/400 Edge Cases

ErrorCauseFix
401 SignatureValidationFailedexpress.json() runs before middlewareRemove the global JSON parser before /webhook
401 behind ngrok / CloudflareBody altered in transitDisable transformations, or use Cloudflare "Cache: bypass"
400 Invalid reply tokenToken re-used or > 30s oldReply once, fast. Use pushMessage for delayed replies
400 The number of messages must be at most 5More than 5 in arraySplit across replyMessage + pushMessage
429 Monthly limit exceededHit free-tier monthly quotaSee LINE Messaging API 2026 updates

A robust webhook should always reply 200 to LINE even if downstream processing fails β€” LINE retries 5xx aggressively, which can cascade. Acknowledge fast, queue work async:

ts
app.post("/webhook", middleware(config), async (req, res) => {
  res.status(200).end()  // ack first
  for (const event of req.body.events) {
    queue.add(() => handleEvent(event)).catch(err => console.error(err))
  }
})

#LINE Bot Webhook Architecture

diagram
LINE Platform                Your Server (Express)             LINE Platform
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   User   β”‚                 β”‚  POST /webhook      β”‚           β”‚ Bot Replyβ”‚
β”‚  sends   β”‚ ──── webhook ──▢│  middleware()       β”‚ ──reply──▢│   to     β”‚
β”‚ message  β”‚   x-line-sig    β”‚   ↓ validates HMAC  β”‚           β”‚   user   β”‚
β”‚          β”‚                 β”‚   ↓ parses JSON     β”‚           β”‚          β”‚
β”‚          β”‚                 β”‚  handleEvent()      β”‚           β”‚          β”‚
β”‚          β”‚                 β”‚   ↓ replyMessage()  β”‚           β”‚          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The webhook is stateless at the LINE side. Your server is responsible for any conversation state, rate limiting and persistence. For richer flows, layer:

#Frequently Asked Questions

Q: Where do I put the Express middleware so signature validation works? Mount it directly on the webhook route, before any express.json(): app.post("/webhook", middleware(config), handler). If you set app.use(express.json()) globally, exclude the webhook path with app.use((req, res, next) => req.path === "/webhook" ? next() : express.json()(req, res, next)).

Q: How do I call validateSignature() directly without Express? import { validateSignature } from "@line/bot-sdk". Pass the raw request body (Buffer or string), the channel secret, and the x-line-signature header value. Returns boolean. Useful for serverless functions where Express isn't ideal.

Q: What does ReplyMessageResponse.sentMessages contain? An array of objects with id (the persistent LINE message ID) and an optional quoteToken (used to mark a future message as a quote reply). One entry per message LINE accepted, in the same order as the request messages array.

Q: Can I use replyMessage and pushMessage together? Yes. Use replyMessage for the immediate reply (free) and pushMessage for later, asynchronous messages (counts against your monthly quota). Common pattern: ack with replyMessage in 1 second, send richer follow-up via pushMessage in 5 seconds.

Q: How does the architecture compare with the Python @from linebot import LineBotApi, WebhookHandler@ pattern? The Python SDK exposes the same primitives β€” WebhookHandler.handle() validates the signature and dispatches to your registered @handler.add(...) decorators. The Node @line/bot-sdk middleware is the closest analogue.

Q: Where do I find a clean LINE bot webhook architecture diagram? The high-level flow is exactly the diagram above: LINE Platform β†’ your webhook (Express middleware verifies + parses) β†’ messagingApi.MessagingApiClient.replyMessage β†’ LINE Platform β†’ user. For solution-level architecture (multi-tenant, queues, retries), see our LINE bot builder comparison.

Q: I'm getting "ratelimit slidingWindow 20, '1 m'" errors. What does that mean? That's a Python rate-limit decorator from limits or slowapi, not LINE itself. LINE Messaging API rate limits are per-channel and described in our LINE Messaging API 2026 updates.


Next steps:

LineBot.pro

Ready to Automate Your LINE Business?

Start automating your LINE communications with LineBot.pro today.