iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔇

Troubleshooting LINE Flex Message Taps: 3 Reasons Why Your 200 OK Response Isn't Triggering Actions

に公開

Introduction

I implemented a feature using the LINE Messaging API to broadcast a Flex Message (a tappable image) that triggers a survey when tapped. The broadcast succeeded, and the webhook returned 200 OK. However, the survey did not start.

There were no error logs. This is a record of how I tracked down three "silent failures" while everything appeared to be working normally.

An important prerequisite here is that a 200 OK from the webhook only means "the LINE Platform received the event," not "the business logic succeeded." Overlooking this distinction makes it impossible to detect silent failures like this one.

System Architecture

┌──────────┐     POST /broadcast     ┌──────────────┐
│  Admin Panel │ ──────────────────────→ │  Backend       │
│ (Next.js) │                        │  (Express)    │
└──────────┘                        └──────┬───────┘
                                           │ Send Flex Message
                                           ▼
                                    ┌──────────────┐
                                    │  LINE Platform │
                                    └──────┬───────┘
                                           │ User taps image
                                           ▼
┌──────────────┐    postback event   ┌──────────────┐
│  Survey      │ ←───────────────── │  Webhook      │
│  Flow Runner │                    │  Endpoint     │
└──────────────┘                    └──────────────┘

Issue 1: Unhandled postback prefix

Symptom

Nothing happens when the Flex Message image is tapped. Checking the webhook endpoint logs, the POST request is received, and it returns 200 OK every time. There are absolutely no error logs.

Cause

When the webhook handler processes a postback event, it sorts the handler based on the prefix of the data field.

// webhook handler's postback processing (before fix)
if (event.type === 'postback' && event.postback?.data) {
  if (data.startsWith('send_msg:')) {
    await this.handleSendMessage(userId, data);
    continue;
  }
  if (data.startsWith('survey_answer:')) {
    await this.handleSurveyAnswer(userId, data);
    continue;
  }
  // ← Even if flex_tap: arrives here, nothing is processed
  // → No continue either, so it falls through to the next check, but skips as no match found
}

The postback data sent by the Flex Message tap action was in the format flex_tap:start_survey:{scenarioId}, but no handler existed for flex_tap:.

Fix

I added a handler for the flex_tap: prefix.

  // webhook handler's postback processing
+ if (event.type === 'postback' && event.postback?.data.startsWith('flex_tap:')) {
+   await this.handleFlexImageTap(userId, event.postback.data);
+   continue;
+ }
+
  if (event.type === 'postback' && event.postback?.data) {
    if (data.startsWith('send_msg:')) {
// Implementation of handleFlexImageTap
private async handleFlexImageTap(userId: string, postbackData: string): Promise<void> {
  const payload = postbackData.replace('flex_tap:', '');

  if (payload.startsWith('start_survey:')) {
    const scenarioId = payload.replace('start_survey:', '');
    const flow = await this.scenarioRepo.findById(scenarioId);
    if (flow && flow.isActive) {
      const runtime = toScenarioRuntime(flow);
      // Start execution from the first node of the scenario
      const startNodeId = findNextNodeId(runtime, triggerNode.id);
      if (startNodeId) {
        await this.executeFromNode(runtime, startNodeId, userId);
      }
    }
  }
}

Issue 2: Survey validation is too strict

Symptom

After fixing Issue 1, the tap-to-start-survey process started working, but a validation error occurred for image-only questions (with only one choice), preventing the survey flow from executing.

Cause

The Zod validation schema for the survey node required a minimum of two choices (min(2)).

// Survey node schema
const surveyNodeSchema = z.object({
  type: z.literal('survey'),
  data: z.object({
    questionText: z.string().min(1),
    imageUrl: z.string().url().optional(),
    choices: z.array(surveyChoiceSchema).min(2), // ← Requires at least 2
  }),
});

This is not a problem for standard text-based choice surveys, but for questions that only require tapping an image (e.g., "Please look at this image and answer" → a single button), there was only one choice, causing validation to fail.

Fix

In this system, image-only questions (with a single confirmation button) are a valid use case, so I relaxed the constraint to min(1).

  data: z.object({
    questionText: z.string().min(1),
    imageUrl: z.string().url().optional(),
-   choices: z.array(surveyChoiceSchema).min(2),
+   choices: z.array(surveyChoiceSchema).min(1),
  }),

Issue 3: Broadcast history not displayed

Symptom

On the broadcast page of the admin dashboard, no history of previously sent broadcasts was displayed. New broadcasts would appear immediately, but they would disappear upon reloading the page.

Cause

This issue was caused by the combination of two missing pieces:

  1. Backend: No GET endpoint existed to retrieve the list of campaigns.
  2. Frontend: There was no process to fetch data when the page mounted.

The broadcast history was managed by useState, and it only had logic to append to the local state upon successful submission.

Frontend: campaigns state = [] (Initial value remains an empty array)
                        ↑
           No process to fetch from the backend
                        ↑
           No GET /broadcast endpoint exists

Fix

Backend: Added a list retrieval endpoint.

// Route definition
router.get('/broadcast', (req, res) => controller.listCampaigns(req, res));

// Controller
async listCampaigns(_req: Request, res: Response): Promise<Response> {
  const campaigns = await this.campaignService.listRecent();
  return res.json({ data: campaigns });
}

Frontend: Added useEffect to fetch data upon mounting.

useEffect(() => {
  const api = getLineAdminAPI();
  api.listCampaigns().then(loaded => {
    setCampaigns(prev => {
      if (prev.length === 0) return loaded;
      // Merge newly sent campaigns with history based on ID
      const existingIds = new Set(prev.map(c => c.id));
      const merged = [...prev, ...loaded.filter(c => !existingIds.has(c.id))];
      return merged.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
    });
  }).catch(() => {});
}, []);

Summary

Issue Cause Fix
No response to tap Missing flex_tap: postback handler Added prefix handler
Survey does not start choices.min(2) caused validation failure for 1 choice Relaxed to min(1)
History not displayed Missing GET endpoint and initial load Added endpoint + useEffect

Key Learnings

  1. Do not rest easy just because the webhook returns 200 OK — Since the LINE webhook is designed to always return 200, unhandled postbacks are silently ignored. Never forget to add a handler when introducing a new postback prefix.
  2. Manage postback prefixes centrally — If handlers are scattered across if-else chains, it is easy to miss adding new prefixes. Managing the mapping between prefixes and handlers centrally improves clarity.
  3. Consider edge cases for validation — The constraint "at least two" is generally correct, but I overlooked use cases like image taps where a single choice is sufficient.
  4. Be wary of the "Write but no Read" pattern — It is surprisingly common to implement the C (Create) of CRUD but forget the R (Read).

Debugging Checklist

  • When introducing a new postback prefix, is there a corresponding branch in the webhook handler?
  • Aside from the webhook response code, are you verifying in logs whether the handler actually executed?
  • Does the validation schema pass for valid edge cases (minimum configuration)?
  • If there is an API to create data, is there also an API to retrieve the list?
  • If the frontend state initial value is empty, does it fetch data on mount?
GitHubで編集を提案

Discussion