<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: unifyport</title>
    <description>The latest articles on DEV Community by unifyport (@unifyport_cool).</description>
    <link>https://dev.to/unifyport_cool</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4037236%2F4426e4ea-496c-49b5-a563-62d96b167f66.png</url>
      <title>DEV Community: unifyport</title>
      <link>https://dev.to/unifyport_cool</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/unifyport_cool"/>
    <language>en</language>
    <item>
      <title>Telegram Bot Receives DMs but Not Group Messages? Debug Privacy Mode First</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:14:34 +0000</pubDate>
      <link>https://dev.to/unifyport/telegram-bot-receives-dms-but-not-group-messages-debug-privacy-mode-first-350g</link>
      <guid>https://dev.to/unifyport/telegram-bot-receives-dms-but-not-group-messages-debug-privacy-mode-first-350g</guid>
      <description>&lt;p&gt;Your Telegram bot receives private messages without a problem.&lt;/p&gt;

&lt;p&gt;In a group, it receives &lt;code&gt;/help@your_bot&lt;/code&gt; and replies directed at it—but ordinary messages never reach your application.&lt;/p&gt;

&lt;p&gt;The first instinct is often to rebuild the webhook, switch to &lt;code&gt;getUpdates&lt;/code&gt;, or promote the bot to administrator.&lt;/p&gt;

&lt;p&gt;Those changes may be unnecessary.&lt;/p&gt;

&lt;p&gt;Telegram enables &lt;strong&gt;Privacy Mode&lt;/strong&gt; for bots by default. A privacy-enabled bot does not receive a complete stream of ordinary group conversation.&lt;/p&gt;

&lt;p&gt;The important distinction is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Group-message visibility and update delivery are separate layers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A working webhook cannot deliver a message Telegram does not expose to the bot. Disabling Privacy Mode also cannot repair a broken webhook, an incorrect bot token, or an application filter that discards &lt;code&gt;message&lt;/code&gt; updates.&lt;/p&gt;

&lt;p&gt;Diagnose the layers independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the observed behavior
&lt;/h2&gt;

&lt;p&gt;Different symptoms point to different parts of the system.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Likely area to inspect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Private messages arrive, but ordinary group text does not&lt;/td&gt;
&lt;td&gt;Privacy Mode and group role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Addressed group commands arrive, but ordinary text does not&lt;/td&gt;
&lt;td&gt;Broad group visibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neither private messages nor addressed commands arrive&lt;/td&gt;
&lt;td&gt;Bot identity, active receiver and update filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram update reaches the HTTP endpoint, but the app shows nothing&lt;/td&gt;
&lt;td&gt;Application filtering or queue processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy Mode was disabled, but behavior did not change&lt;/td&gt;
&lt;td&gt;Remove and re-add the bot, then retest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only one group is affected&lt;/td&gt;
&lt;td&gt;Membership and role in that specific group&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Treat these as diagnostic hypotheses rather than guaranteed conclusions.&lt;/p&gt;

&lt;p&gt;Before changing settings, record which test messages reached the raw receiver.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Privacy Mode controls
&lt;/h2&gt;

&lt;p&gt;Privacy Mode controls which group messages Telegram makes available to a bot.&lt;/p&gt;

&lt;p&gt;With Privacy Mode enabled, a bot can receive interactions relevant to it, including commands explicitly addressed to its username and replies to the bot’s messages.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/help@support_example_bot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An ordinary group message such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Has anyone received their order?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may not be exposed to a privacy-enabled, non-admin bot.&lt;/p&gt;

&lt;p&gt;That behavior is different from private chat, where the user is communicating directly with the bot.&lt;/p&gt;

&lt;p&gt;A successful private-message test therefore proves only that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the bot token can identify a bot;&lt;/li&gt;
&lt;li&gt;at least one receiving path works;&lt;/li&gt;
&lt;li&gt;the application can process that private update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not prove that the bot is entitled to receive ordinary conversation from a particular group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visibility and delivery are different layers
&lt;/h2&gt;

&lt;p&gt;Think of the system as a pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User sends a group message
        ↓
Telegram evaluates bot visibility
        ↓
Telegram creates an eligible update
        ↓
getUpdates or setWebhook delivers it
        ↓
Application filters the update
        ↓
Business logic processes it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failure at each layer looks different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Visibility failure
&lt;/h3&gt;

&lt;p&gt;Telegram never exposes the ordinary group message to the bot.&lt;/p&gt;

&lt;p&gt;Changing webhook code does not fix this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delivery failure
&lt;/h3&gt;

&lt;p&gt;The bot is eligible to receive the update, but the configured polling or webhook receiver does not deliver it correctly.&lt;/p&gt;

&lt;p&gt;Privacy settings do not fix this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Application-filtering failure
&lt;/h3&gt;

&lt;p&gt;The update reaches your endpoint, but your code ignores it.&lt;/p&gt;

&lt;p&gt;Changing the bot’s permissions does not fix this.&lt;/p&gt;

&lt;p&gt;Good troubleshooting identifies the failed layer before modifying the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: confirm the bot identity
&lt;/h2&gt;

&lt;p&gt;A surprisingly common cause is deploying the wrong bot token.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;getMe&lt;/code&gt; from a trusted environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getMe"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not paste the bot token into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;screenshots;&lt;/li&gt;
&lt;li&gt;shared shell logs;&lt;/li&gt;
&lt;li&gt;support tickets;&lt;/li&gt;
&lt;li&gt;source code;&lt;/li&gt;
&lt;li&gt;chat messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the returned bot identity against the username added to the affected group.&lt;/p&gt;

&lt;p&gt;A staging bot and a production bot may have similar names while using completely different tokens and privacy settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check &lt;code&gt;can_read_all_group_messages&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;getMe&lt;/code&gt; response may include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123456789&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"is_bot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"first_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Support Bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support_example_bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"can_join_groups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"can_read_all_group_messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant field is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;can_read_all_group_messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to the Telegram Bot API, &lt;code&gt;true&lt;/code&gt; means Privacy Mode is disabled.&lt;/p&gt;

&lt;p&gt;This field describes the bot’s Privacy Mode status. It is not a report of the bot’s role in every group.&lt;/p&gt;

&lt;p&gt;You must still inspect its membership and permissions in the affected group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: run a controlled group test
&lt;/h2&gt;

&lt;p&gt;Use a dedicated test group with informed participants.&lt;/p&gt;

&lt;p&gt;Send new messages from a human Telegram account in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A private text message to the bot.&lt;/li&gt;
&lt;li&gt;A command addressed to the bot’s exact username.&lt;/li&gt;
&lt;li&gt;A reply to a message previously sent by the bot.&lt;/li&gt;
&lt;li&gt;An ordinary group text message.&lt;/li&gt;
&lt;li&gt;A new ordinary message after any approved configuration change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example test messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Private chat:
privacy-test-private-001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Group:
 /status@support_example_bot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reply to bot:
privacy-test-reply-001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ordinary group text:
privacy-test-group-001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use distinct values so you can search logs and queue records unambiguously.&lt;/p&gt;

&lt;p&gt;Do not use historical messages as the test. Changing permissions does not imply recovery of messages that were never delivered.&lt;/p&gt;

&lt;p&gt;Also avoid using another bot as the sender. Bot-to-bot behavior introduces a different set of rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: inspect the raw receiver
&lt;/h2&gt;

&lt;p&gt;Before investigating business logic, determine whether the Telegram update reached your receiving boundary.&lt;/p&gt;

&lt;p&gt;For a webhook-based service, temporarily record safe metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/telegram/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;telegram_update_received&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;updateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;hasMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;chatType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;storeUpdateIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not log the bot token or unnecessary message content.&lt;/p&gt;

&lt;p&gt;If the update exists in raw storage but not in the product UI, Privacy Mode is not the immediate cause. The problem is now inside the application.&lt;/p&gt;

&lt;p&gt;Inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;event-type conditions;&lt;/li&gt;
&lt;li&gt;chat-type filters;&lt;/li&gt;
&lt;li&gt;queue insertion;&lt;/li&gt;
&lt;li&gt;duplicate detection;&lt;/li&gt;
&lt;li&gt;worker failures;&lt;/li&gt;
&lt;li&gt;database transactions;&lt;/li&gt;
&lt;li&gt;downstream routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: audit application filters
&lt;/h2&gt;

&lt;p&gt;A handler may accidentally accept only private chats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;private&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or it may accept only commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A more explicit router makes the boundary easier to inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classifyTelegramUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsupported_update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;private&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;private_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;supergroup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsupported_chat_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application should make its supported chat types visible rather than silently discarding them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: check &lt;code&gt;allowed_updates&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Telegram’s &lt;code&gt;allowed_updates&lt;/code&gt; parameter controls which update types are delivered through the Bot API receiver.&lt;/p&gt;

&lt;p&gt;For a group text-message test, the configuration must allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A conceptual configuration looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowed_updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Privacy Mode and &lt;code&gt;allowed_updates&lt;/code&gt; solve different problems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Privacy Mode:
Is the bot eligible to see this group message?

allowed_updates:
Should this eligible update type be delivered to the receiver?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Broader group visibility cannot fix an &lt;code&gt;allowed_updates&lt;/code&gt; configuration that excludes &lt;code&gt;message&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Similarly, adding &lt;code&gt;message&lt;/code&gt; cannot grant visibility that Telegram has not given the bot.&lt;/p&gt;

&lt;p&gt;Telegram also documents that omitting &lt;code&gt;allowed_updates&lt;/code&gt; can preserve the previous configuration. Do not assume omission resets it to every update type.&lt;/p&gt;

&lt;p&gt;Audit the configuration used by the currently active receiver.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: verify the receiving mode
&lt;/h2&gt;

&lt;p&gt;Telegram Bot API supports two mutually exclusive receiving modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;getUpdates&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setWebhook&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If private messages and addressed commands arrive through the same receiver, transport is probably not the first problem to change.&lt;/p&gt;

&lt;p&gt;If nothing arrives at all, inspect the active receiver with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-empty webhook URL means a webhook is configured.&lt;/p&gt;

&lt;p&gt;Do not switch between polling and webhook delivery merely to test Privacy Mode. Delivery transport does not expand group visibility.&lt;/p&gt;

&lt;p&gt;Diagnose transport separately using a controlled migration runbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: choose the minimum required access
&lt;/h2&gt;

&lt;p&gt;Before disabling Privacy Mode, ask whether the bot truly needs every ordinary group message.&lt;/p&gt;

&lt;p&gt;A command-driven bot may need only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/help@bot
/status@bot
/assign@bot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reply-based workflow may need only messages explicitly directed at the bot.&lt;/p&gt;

&lt;p&gt;In those cases, keeping Privacy Mode enabled reduces unnecessary data collection.&lt;/p&gt;

&lt;p&gt;Consider disabling it only when the product genuinely needs broad group observation, for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;support triage across all group conversation;&lt;/li&gt;
&lt;li&gt;moderation of ordinary user messages;&lt;/li&gt;
&lt;li&gt;group analytics with informed participants;&lt;/li&gt;
&lt;li&gt;workflows triggered by non-command text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat broader visibility as a privacy and access decision, not as a performance optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not promote the bot to administrator just for testing
&lt;/h2&gt;

&lt;p&gt;Administrator status carries permissions and responsibilities beyond receiving messages.&lt;/p&gt;

&lt;p&gt;Promoting the bot may change behavior, but it also changes the security model.&lt;/p&gt;

&lt;p&gt;Do not combine all these changes in one experiment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Disable Privacy Mode
Promote bot to administrator
Replace webhook
Change allowed_updates
Deploy new handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the test starts working, you will not know which change mattered.&lt;/p&gt;

&lt;p&gt;Change one layer at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Confirm identity
2. Record baseline tests
3. Check Privacy Mode
4. Check group role
5. Check allowed_updates
6. Inspect raw delivery
7. Inspect application filters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This preserves causal evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disabling Privacy Mode safely
&lt;/h2&gt;

&lt;p&gt;If ordinary group conversation is required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the correct bot with &lt;code&gt;getMe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Explain the broader collection scope to group owners.&lt;/li&gt;
&lt;li&gt;Change Privacy Mode through BotFather using &lt;code&gt;/setprivacy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Remove the bot from the test group.&lt;/li&gt;
&lt;li&gt;Re-add the bot to the group.&lt;/li&gt;
&lt;li&gt;Verify its intended role.&lt;/li&gt;
&lt;li&gt;Send a new human-authored test message.&lt;/li&gt;
&lt;li&gt;Inspect the raw receiver before application output.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Telegram documents that the bot must be re-added to the group after Privacy Mode is disabled for the change to take effect.&lt;/p&gt;

&lt;p&gt;Do not skip the re-add step and conclude that the setting failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build an acceptance-test matrix
&lt;/h2&gt;

&lt;p&gt;Record results instead of relying on memory.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Privacy enabled&lt;/th&gt;
&lt;th&gt;Privacy disabled and bot re-added&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Private text&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Addressed command&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply to bot message&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;td&gt;Expected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordinary human group text&lt;/td&gt;
&lt;td&gt;Restricted by Privacy Mode&lt;/td&gt;
&lt;td&gt;Expected when other configuration is correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message visible in raw receiver&lt;/td&gt;
&lt;td&gt;Record result&lt;/td&gt;
&lt;td&gt;Record result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message processed by application&lt;/td&gt;
&lt;td&gt;Record result&lt;/td&gt;
&lt;td&gt;Record result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rightmost column is not a guarantee that every application will work. The receiving mode, update filters, group role and handler must still be correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve updates idempotently
&lt;/h2&gt;

&lt;p&gt;Once a message reaches the application, use &lt;code&gt;update_id&lt;/code&gt; as the delivery-level idempotency key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;storeUpdateIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telegramUpdates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;updateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueTelegramUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents webhook retries or polling restarts from creating duplicate downstream work.&lt;/p&gt;

&lt;p&gt;Business operations may need their own idempotency keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;support-ticket:{botId}:{updateId}
moderation-action:{botId}:{updateId}
notification:{botId}:{updateId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Receiving the same update twice should not create two tickets or apply the same moderation action twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  A compact diagnostic function
&lt;/h2&gt;

&lt;p&gt;You can turn the troubleshooting logic into an internal checklist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;diagnoseGroupVisibility&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;privateMessageReceived&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;addressedCommandReceived&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ordinaryGroupMessageReceived&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rawUpdateReceived&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;privacyDisabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;botReadded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;messageAllowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;privateMessageReceived&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;addressedCommandReceived&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Check bot identity, receiver mode and allowed_updates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;addressedCommandReceived&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ordinaryGroupMessageReceived&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;privacyDisabled&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Behavior is consistent with Privacy Mode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privacyDisabled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;botReadded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Remove and re-add the bot, then send a new test message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;messageAllowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Update configuration must include message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawUpdateReceived&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ordinaryGroupMessageReceived&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inspect application filtering and downstream processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inspect group role and compare raw updates with application records&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not replace Telegram’s documentation, but it stops incident responders from making unrelated changes simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bot identity versus account-based intake
&lt;/h2&gt;

&lt;p&gt;This guide concerns Telegram Bot API bots.&lt;/p&gt;

&lt;p&gt;A bot is the right model when users should interact with a bot identity through commands, replies and bot-specific workflows.&lt;/p&gt;

&lt;p&gt;An account-based intake system solves a different problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Existing Telegram account
        ↓
Authorized account connection
        ↓
Normalized inbound events
        ↓
Shared multi-channel queue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UnifyPort’s unofficial interface can connect a Telegram account and emit normalized &lt;code&gt;message.received&lt;/code&gt; events alongside WhatsApp, LINE, TikTok, Zalo and X.&lt;/p&gt;

&lt;p&gt;That architecture is not a way to change BotFather settings or repair a Bot API webhook.&lt;/p&gt;

&lt;p&gt;It also does not grant access to arbitrary groups or guarantee recovery of historical messages.&lt;/p&gt;

&lt;p&gt;Choose based on the required identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Users should talk to a bot
    → Telegram Bot API

Team needs intake from an authorized existing account
    → Evaluate an account-based integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;p&gt;Before declaring the issue fixed, verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;getMe&lt;/code&gt; returns the expected bot identity.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;can_read_all_group_messages&lt;/code&gt; matches the intended Privacy Mode.&lt;/li&gt;
&lt;li&gt;[ ] The bot belongs to the affected group.&lt;/li&gt;
&lt;li&gt;[ ] Its group role is intentional.&lt;/li&gt;
&lt;li&gt;[ ] The test uses a human sender.&lt;/li&gt;
&lt;li&gt;[ ] The command includes the correct bot username.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;allowed_updates&lt;/code&gt; includes &lt;code&gt;message&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] The intended receiver is active.&lt;/li&gt;
&lt;li&gt;[ ] New tests reach raw update storage.&lt;/li&gt;
&lt;li&gt;[ ] Application filters accept &lt;code&gt;group&lt;/code&gt; and &lt;code&gt;supergroup&lt;/code&gt; where required.&lt;/li&gt;
&lt;li&gt;[ ] Update IDs are stored idempotently.&lt;/li&gt;
&lt;li&gt;[ ] The bot was re-added after disabling Privacy Mode.&lt;/li&gt;
&lt;li&gt;[ ] Broader access was approved by the group owner.&lt;/li&gt;
&lt;li&gt;[ ] Logs exclude the bot token and unnecessary message content.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When a Telegram bot receives private messages but misses ordinary group conversation, do not rebuild the webhook first.&lt;/p&gt;

&lt;p&gt;Diagnose the pipeline in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bot identity
    ↓
Privacy Mode
    ↓
Group role
    ↓
allowed_updates
    ↓
Delivery transport
    ↓
Application filters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An addressed command arriving while ordinary group text does not is strong evidence that the receiver works for at least one eligible group interaction.&lt;/p&gt;

&lt;p&gt;Decide whether broader visibility is actually required. If it is, disable Privacy Mode deliberately, re-add the bot, and retest with new human-authored messages.&lt;/p&gt;

&lt;p&gt;Change permissions, transport and application code separately so each test tells you something useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/features" rel="noopener noreferrer"&gt;Telegram bot features and Privacy Mode&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/api" rel="noopener noreferrer"&gt;Telegram Bot API: getMe and update delivery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/blog/telegram-getupdates-setwebhook-switching-runbook/" rel="noopener noreferrer"&gt;Telegram &lt;code&gt;getUpdates&lt;/code&gt; and &lt;code&gt;setWebhook&lt;/code&gt; switching runbook&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/provider-guide/provider-auth-telegram/" rel="noopener noreferrer"&gt;UnifyPort Telegram authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-events/" rel="noopener noreferrer"&gt;UnifyPort webhook event reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-delivery/" rel="noopener noreferrer"&gt;UnifyPort webhook delivery and signature verification&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>webhooks</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Telegram getUpdates vs setWebhook: A Safe Migration Runbook</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Wed, 09 Sep 2026 07:52:50 +0000</pubDate>
      <link>https://dev.to/unifyport/telegram-getupdates-vs-setwebhook-a-safe-migration-runbook-59dg</link>
      <guid>https://dev.to/unifyport/telegram-getupdates-vs-setwebhook-a-safe-migration-runbook-59dg</guid>
      <description>&lt;p&gt;Your Telegram bot worked yesterday.&lt;/p&gt;

&lt;p&gt;After a deployment, one of these things happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;getUpdates&lt;/code&gt; returns no messages;&lt;/li&gt;
&lt;li&gt;the webhook endpoint receives nothing;&lt;/li&gt;
&lt;li&gt;old updates suddenly arrive in a flood;&lt;/li&gt;
&lt;li&gt;the application processes some messages twice;&lt;/li&gt;
&lt;li&gt;both teams insist their receiver is configured correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The underlying rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Telegram Bot API polling with &lt;code&gt;getUpdates&lt;/code&gt; and push delivery with &lt;code&gt;setWebhook&lt;/code&gt; are mutually exclusive for the same bot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are two alternative delivery modes—not two layers that should run in parallel.&lt;/p&gt;

&lt;p&gt;A safe migration therefore requires four things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Inspect the current mode
        ↓
Stop the current receiver
        ↓
Choose what happens to pending updates
        ↓
Start and verify the new receiver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runbook covers both migration directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two receiving modes
&lt;/h2&gt;

&lt;p&gt;Telegram bots can receive updates through polling or webhooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Long polling with &lt;code&gt;getUpdates&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Your application repeatedly asks Telegram for new updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application → Telegram: Do you have updates?
Telegram → Application: Here are the updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getUpdates?timeout=30"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Polling is often convenient for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;local development;&lt;/li&gt;
&lt;li&gt;small bot deployments;&lt;/li&gt;
&lt;li&gt;workers without a public HTTPS endpoint;&lt;/li&gt;
&lt;li&gt;simple single-process applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your worker owns the delivery loop and must advance the update &lt;code&gt;offset&lt;/code&gt; correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Push delivery with &lt;code&gt;setWebhook&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Telegram sends each update to your HTTPS endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram → Application: Here is an update
Application → Telegram: HTTP 2xx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/setWebhook"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"url=https://support.example.com/telegram/bot-webhook"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhooks are often a better fit for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;production HTTP services;&lt;/li&gt;
&lt;li&gt;horizontally scalable consumers;&lt;/li&gt;
&lt;li&gt;low-latency processing;&lt;/li&gt;
&lt;li&gt;infrastructure that already receives external events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoint should acknowledge requests quickly and move slow work to a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the conflict happens
&lt;/h2&gt;

&lt;p&gt;When a webhook is configured, Telegram does not allow the same bot to receive updates through &lt;code&gt;getUpdates&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A polling worker may still be running, but it is no longer the active Telegram delivery path.&lt;/p&gt;

&lt;p&gt;This creates a confusing operational state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Polling process: healthy and still running
Telegram webhook: configured
getUpdates: unable to receive updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From an infrastructure dashboard, the polling worker looks healthy. From the bot’s perspective, it is no longer receiving anything.&lt;/p&gt;

&lt;p&gt;The reverse migration can also fail when a team deploys a webhook receiver but forgets to stop or retire the previous polling deployment.&lt;/p&gt;

&lt;p&gt;Even when Telegram itself has one active delivery mode, multiple application instances may still compete inside your infrastructure and create duplicate downstream processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  First step: inspect the active mode
&lt;/h2&gt;

&lt;p&gt;Before changing a deployment flag, call &lt;code&gt;getWebhookInfo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;BOT_TOKEN&lt;/code&gt; in an environment variable. Do not paste it into shell history, tickets, screenshots or application logs.&lt;/p&gt;

&lt;p&gt;The most important field for this diagnosis is &lt;code&gt;url&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;url is non-empty → a Telegram Bot API webhook is configured
url is empty     → no Bot API webhook is configured
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not guess the active mode from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which service is currently deployed;&lt;/li&gt;
&lt;li&gt;which process is running;&lt;/li&gt;
&lt;li&gt;what the environment variable says;&lt;/li&gt;
&lt;li&gt;what the previous release was supposed to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Telegram’s current webhook configuration is the state that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose common symptoms
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely explanation&lt;/th&gt;
&lt;th&gt;First check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;getUpdates&lt;/code&gt; stopped returning messages&lt;/td&gt;
&lt;td&gt;A webhook is still configured&lt;/td&gt;
&lt;td&gt;Call &lt;code&gt;getWebhookInfo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook receives nothing&lt;/td&gt;
&lt;td&gt;Webhook was not set correctly or points to the wrong endpoint&lt;/td&gt;
&lt;td&gt;Inspect &lt;code&gt;getWebhookInfo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old updates arrive after switching&lt;/td&gt;
&lt;td&gt;Pending updates were retained&lt;/td&gt;
&lt;td&gt;Review the backlog policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Updates are processed twice internally&lt;/td&gt;
&lt;td&gt;Multiple application consumers or non-idempotent storage&lt;/td&gt;
&lt;td&gt;Check worker ownership and update IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test messages disappear&lt;/td&gt;
&lt;td&gt;Pending updates may have been dropped during cutover&lt;/td&gt;
&lt;td&gt;Check the migration command and logs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Treat the receiving mode and your internal processing topology as separate problems.&lt;/p&gt;

&lt;p&gt;Telegram may have only one active delivery mode while your backend still has two consumers reading the same internal queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration rule: one receiver owns the bot
&lt;/h2&gt;

&lt;p&gt;Before switching, define one owner for the bot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;telegram-receiver-mode = polling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;telegram-receiver-mode = webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not leave both production deployments enabled and rely on timing.&lt;/p&gt;

&lt;p&gt;A useful deployment invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At every point in the migration, exactly one system is designated to process new Telegram updates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A brief period with no active application receiver is safer than an uncontrolled period in which multiple consumers can create duplicate side effects.&lt;/p&gt;

&lt;p&gt;Pending updates must then be handled according to an explicit policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching from webhook to &lt;code&gt;getUpdates&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Use this direction when returning to polling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: pause downstream changes
&lt;/h3&gt;

&lt;p&gt;Temporarily prevent bot updates from triggering irreversible side effects during the cutover.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sending duplicate replies;&lt;/li&gt;
&lt;li&gt;creating duplicate support tickets;&lt;/li&gt;
&lt;li&gt;charging a customer twice;&lt;/li&gt;
&lt;li&gt;applying the same moderation action twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your update processing should already be idempotent, but the migration is a good time to verify that assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: inspect the webhook
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm which URL is configured before deleting it.&lt;/p&gt;

&lt;p&gt;This avoids removing an unexpected production receiver based on a stale deployment assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: choose the pending-update policy
&lt;/h3&gt;

&lt;p&gt;Remove the webhook using &lt;code&gt;deleteWebhook&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/deleteWebhook"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"drop_pending_updates=false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical parameter is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;drop_pending_updates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;false&lt;/code&gt; when pending messages still matter.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;true&lt;/code&gt; only when the backlog can be discarded deliberately.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Suggested choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production support messages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer orders or requests&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moderation events&lt;/td&gt;
&lt;td&gt;Usually &lt;code&gt;false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disposable test traffic&lt;/td&gt;
&lt;td&gt;Possibly &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Corrupted or unsafe backlog&lt;/td&gt;
&lt;td&gt;Make an explicit incident decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean non-production reset&lt;/td&gt;
&lt;td&gt;Possibly &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do not use &lt;code&gt;true&lt;/code&gt; merely because it makes the migration easier.&lt;/p&gt;

&lt;p&gt;Dropping pending updates is a business-data decision, not just a technical cleanup option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: verify webhook removal
&lt;/h3&gt;

&lt;p&gt;Call &lt;code&gt;getWebhookInfo&lt;/code&gt; again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm that the webhook &lt;code&gt;url&lt;/code&gt; is empty.&lt;/p&gt;

&lt;p&gt;Do this before starting the polling worker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: start exactly one polling worker
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getUpdates?timeout=30"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, only one logical polling owner should fetch updates for that bot.&lt;/p&gt;

&lt;p&gt;If several application replicas all start polling independently, your deployment becomes difficult to reason about even though no webhook is configured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: advance the offset
&lt;/h3&gt;

&lt;p&gt;After processing a response, advance &lt;code&gt;offset&lt;/code&gt; beyond the highest processed &lt;code&gt;update_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A simplified worker looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pollTelegram&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;30&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`https://api.telegram.org/bot&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BOT_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/getUpdates?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`getUpdates failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processUpdateIdempotently&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a durable production implementation, do not keep the confirmed offset only in process memory.&lt;/p&gt;

&lt;p&gt;If the worker restarts after performing a side effect but before persisting progress, the same update may be processed again.&lt;/p&gt;

&lt;p&gt;Store update IDs and processing state durably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching from &lt;code&gt;getUpdates&lt;/code&gt; to &lt;code&gt;setWebhook&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Use this direction when moving from polling to push delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: stop the polling worker
&lt;/h3&gt;

&lt;p&gt;Stop or scale down the polling deployment first.&lt;/p&gt;

&lt;p&gt;Confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no polling process is still running;&lt;/li&gt;
&lt;li&gt;no scheduled job calls &lt;code&gt;getUpdates&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;no developer machine uses the production bot token;&lt;/li&gt;
&lt;li&gt;no old deployment remains active in another region.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stopping the visible worker is not enough if another automation still polls the same bot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: finish or checkpoint current work
&lt;/h3&gt;

&lt;p&gt;Allow in-flight updates to complete or store them durably before terminating the worker.&lt;/p&gt;

&lt;p&gt;The polling application should record which &lt;code&gt;update_id&lt;/code&gt; values were accepted so the webhook receiver can continue processing idempotently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: deploy the webhook endpoint
&lt;/h3&gt;

&lt;p&gt;The endpoint should be ready before you register it with Telegram.&lt;/p&gt;

&lt;p&gt;A minimal Express receiver might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/telegram/bot-webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;storeUpdateIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;updateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueTelegramUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a production implementation, separate the HTTP acknowledgment from slow CRM, database-enrichment or AI operations.&lt;/p&gt;

&lt;p&gt;The reliable pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receive
  ↓
Validate
  ↓
Store idempotently
  ↓
Return success
  ↓
Process asynchronously
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: configure the webhook
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/setWebhook"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"url=https://support.example.com/telegram/bot-webhook"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a production HTTPS endpoint controlled by your team.&lt;/p&gt;

&lt;p&gt;Do not point a production bot at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a developer laptop;&lt;/li&gt;
&lt;li&gt;a temporary tunnel with an unknown lifetime;&lt;/li&gt;
&lt;li&gt;a staging endpoint;&lt;/li&gt;
&lt;li&gt;an endpoint that performs slow work before responding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 5: verify the configured URL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$BOT_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm that the returned URL is exactly the endpoint you intended to configure.&lt;/p&gt;

&lt;p&gt;This catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;environment mix-ups;&lt;/li&gt;
&lt;li&gt;incorrect paths;&lt;/li&gt;
&lt;li&gt;staging URLs;&lt;/li&gt;
&lt;li&gt;accidental trailing route differences;&lt;/li&gt;
&lt;li&gt;deployment-variable mistakes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 6: send a controlled test message
&lt;/h3&gt;

&lt;p&gt;Send one identifiable test message to the bot.&lt;/p&gt;

&lt;p&gt;Verify the entire path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram accepted message
        ↓
Webhook received update
        ↓
Update stored once
        ↓
Queue job created
        ↓
Downstream handler processed it once
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful &lt;code&gt;setWebhook&lt;/code&gt; call alone does not prove that your application is receiving and processing updates correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle pending updates deliberately
&lt;/h2&gt;

&lt;p&gt;Telegram stores pending updates temporarily, so a migration should not be left half-finished.&lt;/p&gt;

&lt;p&gt;Before switching, decide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are pending messages business-critical?&lt;/li&gt;
&lt;li&gt;Can the new receiver process old updates idempotently?&lt;/li&gt;
&lt;li&gt;Is the backlog entirely disposable test traffic?&lt;/li&gt;
&lt;li&gt;Does processing old commands after deployment create risk?&lt;/li&gt;
&lt;li&gt;Who is authorized to approve dropping the backlog?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Document the decision in the deployment record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pending update policy: retain and drain
Approved by: support operations
Receiver before: webhook
Receiver after: polling
Cutover time: 2026-09-09T10:00:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never record the bot token in the deployment record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make both modes idempotent
&lt;/h2&gt;

&lt;p&gt;Regardless of the delivery method, store each Telegram &lt;code&gt;update_id&lt;/code&gt; once.&lt;/p&gt;

&lt;p&gt;A conceptual database table could be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;telegram_updates&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;bot_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;update_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique key prevents a repeated update from creating another business action.&lt;/p&gt;

&lt;p&gt;A handler can then claim stored work atomically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processUpdateIdempotently&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertUpdateIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Idempotency should be based on Telegram’s stable update identifier, not message text or arrival time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoid duplicate side effects
&lt;/h2&gt;

&lt;p&gt;Even with unique update storage, downstream operations may still need their own idempotency keys.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;telegram-reply:{botId}:{updateId}
support-ticket:{botId}:{updateId}
moderation-action:{botId}:{updateId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the worker crashes after creating a support ticket but before marking the update complete, a retry should find the existing ticket instead of creating a second one.&lt;/p&gt;

&lt;p&gt;Delivery deduplication and business-operation deduplication are related but separate safeguards.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration checklist
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before switching
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Confirm the bot token belongs to the expected bot.&lt;/li&gt;
&lt;li&gt;[ ] Call &lt;code&gt;getWebhookInfo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Record the active receiving mode.&lt;/li&gt;
&lt;li&gt;[ ] Identify every polling worker and webhook deployment.&lt;/li&gt;
&lt;li&gt;[ ] Decide whether pending updates must be retained.&lt;/li&gt;
&lt;li&gt;[ ] Verify update processing is idempotent.&lt;/li&gt;
&lt;li&gt;[ ] Prepare the destination receiver.&lt;/li&gt;
&lt;li&gt;[ ] Keep the bot token out of logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Webhook to polling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Call &lt;code&gt;deleteWebhook&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;drop_pending_updates&lt;/code&gt; deliberately.&lt;/li&gt;
&lt;li&gt;[ ] Confirm the webhook URL is empty.&lt;/li&gt;
&lt;li&gt;[ ] Start exactly one polling owner.&lt;/li&gt;
&lt;li&gt;[ ] Persist processed update IDs.&lt;/li&gt;
&lt;li&gt;[ ] Advance the polling offset.&lt;/li&gt;
&lt;li&gt;[ ] Drain retained updates.&lt;/li&gt;
&lt;li&gt;[ ] Test one new message.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Polling to webhook
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Stop every polling worker.&lt;/li&gt;
&lt;li&gt;[ ] Finish or checkpoint in-flight work.&lt;/li&gt;
&lt;li&gt;[ ] Deploy the webhook receiver.&lt;/li&gt;
&lt;li&gt;[ ] Configure the production webhook URL.&lt;/li&gt;
&lt;li&gt;[ ] Verify it with &lt;code&gt;getWebhookInfo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Send a controlled test message.&lt;/li&gt;
&lt;li&gt;[ ] Confirm the update was stored once.&lt;/li&gt;
&lt;li&gt;[ ] Confirm downstream processing occurred once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When the Bot API is the right model
&lt;/h2&gt;

&lt;p&gt;Use Telegram’s official Bot API when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;users should interact with a bot identity;&lt;/li&gt;
&lt;li&gt;the Telegram &lt;code&gt;Update&lt;/code&gt; object is the right event contract;&lt;/li&gt;
&lt;li&gt;bot commands are central to the product;&lt;/li&gt;
&lt;li&gt;the integration is intentionally Telegram-specific;&lt;/li&gt;
&lt;li&gt;polling or Telegram-native webhooks fit your infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In that case, choose either &lt;code&gt;getUpdates&lt;/code&gt; or &lt;code&gt;setWebhook&lt;/code&gt; and operate it clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a unified inbound webhook fits better
&lt;/h2&gt;

&lt;p&gt;The Bot API runbook solves delivery for one Telegram bot.&lt;/p&gt;

&lt;p&gt;It does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connect an ordinary Telegram user account;&lt;/li&gt;
&lt;li&gt;normalize WhatsApp, LINE, TikTok, Zalo or X;&lt;/li&gt;
&lt;li&gt;create a shared cross-channel inbox contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your actual requirement is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receive customer messages from several existing accounts
        ↓
Store them in one event model
        ↓
Route them to the same support queue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then a normalized inbound webhook may be a cleaner architecture.&lt;/p&gt;

&lt;p&gt;A Telegram event delivered through UnifyPort can use the same envelope as other connected providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_b1a7c3e5f8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acc_8c21d0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-09T12:37:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5005"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4004"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jordan Lee"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3003"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sent_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-09T12:37:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Can you check my order?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message_received"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the Bot API when the identity should be a Telegram bot.&lt;/p&gt;

&lt;p&gt;Use an account-based inbound interface when an existing messaging account or cross-platform support queue is the real requirement.&lt;/p&gt;

&lt;p&gt;These are different product models, not interchangeable transport settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When a Telegram bot stops receiving updates after a deployment, start with one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What does getWebhookInfo say?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then follow a controlled migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Inspect
  ↓
Stop the old receiver
  ↓
Choose the pending-update policy
  ↓
Start the new receiver
  ↓
Verify one end-to-end message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never treat &lt;code&gt;getUpdates&lt;/code&gt; and &lt;code&gt;setWebhook&lt;/code&gt; as parallel delivery layers.&lt;/p&gt;

&lt;p&gt;Pick one owner, store updates idempotently, and make the pending-backlog decision explicit before switching.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/api#getupdates" rel="noopener noreferrer"&gt;Telegram Bot API: getUpdates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/api#setwebhook" rel="noopener noreferrer"&gt;Telegram Bot API: setWebhook&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/api#deletewebhook" rel="noopener noreferrer"&gt;Telegram Bot API: deleteWebhook&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/webhooks" rel="noopener noreferrer"&gt;Telegram webhook guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/webhook-endpoints/create-webhook/" rel="noopener noreferrer"&gt;UnifyPort: Create a webhook endpoint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-delivery/" rel="noopener noreferrer"&gt;UnifyPort: Webhook delivery and signature verification&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>api</category>
      <category>devops</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>TikTok Business Messaging API vs QR-Authenticated Inbox: How to Choose</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:22:19 +0000</pubDate>
      <link>https://dev.to/unifyport/tiktok-business-messaging-api-vs-qr-authenticated-inbox-how-to-choose-hj</link>
      <guid>https://dev.to/unifyport/tiktok-business-messaging-api-vs-qr-authenticated-inbox-how-to-choose-hj</guid>
      <description>&lt;p&gt;“We need TikTok DMs in our backend” sounds like a clear technical requirement.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;Before selecting an API, you need to answer a more fundamental question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which TikTok identity and operating model are you building around?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are two different integration paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;TikTok’s official Business Messaging API.&lt;/li&gt;
&lt;li&gt;A QR-authenticated inbox connected to a normalized webhook.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both can bring direct messages into a backend, but they solve different problems.&lt;/p&gt;

&lt;p&gt;The official API is designed around TikTok Business Account capabilities and TikTok-native messaging workflows. A QR-authenticated inbox starts with an existing account that already receives customer messages and connects that inbox to your operational backend.&lt;/p&gt;

&lt;p&gt;Choosing between them is an architecture decision—not merely an authentication preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;TikTok Business Messaging API&lt;/th&gt;
&lt;th&gt;QR-authenticated inbox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary identity&lt;/td&gt;
&lt;td&gt;TikTok Business Account&lt;/td&gt;
&lt;td&gt;Existing TikTok account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;TikTok-focused business messaging&lt;/td&gt;
&lt;td&gt;Multi-channel support inbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup focus&lt;/td&gt;
&lt;td&gt;App access, authorization, reviews and limits&lt;/td&gt;
&lt;td&gt;Account connection, QR authentication and webhook delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event model&lt;/td&gt;
&lt;td&gt;TikTok-specific API and webhooks&lt;/td&gt;
&lt;td&gt;Normalized message events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other channels&lt;/td&gt;
&lt;td&gt;Build separate integrations&lt;/td&gt;
&lt;td&gt;Reuse the same intake contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main advantage&lt;/td&gt;
&lt;td&gt;Official TikTok business capabilities&lt;/td&gt;
&lt;td&gt;Faster integration with an existing operational inbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main trade-off&lt;/td&gt;
&lt;td&gt;Platform-specific access and implementation&lt;/td&gt;
&lt;td&gt;Session lifecycle and re-authentication operations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A useful starting rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Need TikTok-native business features?
    → Evaluate the official Business Messaging API

Need messages from an existing inbox in a shared multi-channel queue?
    → Evaluate a QR-authenticated inbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Start with the identity
&lt;/h2&gt;

&lt;p&gt;Many teams begin by comparing endpoints, payloads, or SDKs.&lt;/p&gt;

&lt;p&gt;Start with identity instead.&lt;/p&gt;

&lt;p&gt;Ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the account a TikTok Business Account?&lt;/li&gt;
&lt;li&gt;Does the product depend on official business-messaging capabilities?&lt;/li&gt;
&lt;li&gt;Are messages connected to paid-media or campaign workflows?&lt;/li&gt;
&lt;li&gt;Does the team already operate a TikTok inbox manually?&lt;/li&gt;
&lt;li&gt;Must TikTok messages enter the same queue as WhatsApp, LINE or Zalo?&lt;/li&gt;
&lt;li&gt;Can the team operate session renewal and re-authentication safely?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answers determine which integration model fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the official Business Messaging API is for
&lt;/h2&gt;

&lt;p&gt;TikTok’s Business Messaging documentation describes an official API surface for direct-message workflows.&lt;/p&gt;

&lt;p&gt;Its documentation includes capabilities related to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;conversations;&lt;/li&gt;
&lt;li&gt;sending and retrieving messages;&lt;/li&gt;
&lt;li&gt;media upload and download;&lt;/li&gt;
&lt;li&gt;webhook configuration;&lt;/li&gt;
&lt;li&gt;Business Account capability checks;&lt;/li&gt;
&lt;li&gt;automatic-message management.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This path is appropriate when TikTok itself is a core product surface.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a TikTok-first sales platform;&lt;/li&gt;
&lt;li&gt;business messaging connected to advertising campaigns;&lt;/li&gt;
&lt;li&gt;workflows that depend on official Business Account capabilities;&lt;/li&gt;
&lt;li&gt;applications requiring a platform-supported integration model;&lt;/li&gt;
&lt;li&gt;products that need TikTok-specific messaging operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before committing to this path, review TikTok’s current requirements for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;authorization;&lt;/li&gt;
&lt;li&gt;account eligibility;&lt;/li&gt;
&lt;li&gt;application access;&lt;/li&gt;
&lt;li&gt;data-security review;&lt;/li&gt;
&lt;li&gt;regional availability;&lt;/li&gt;
&lt;li&gt;rate limits;&lt;/li&gt;
&lt;li&gt;return codes;&lt;/li&gt;
&lt;li&gt;production approval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not assume that finding an endpoint in the documentation means every application or account can immediately use it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a QR-authenticated inbox is for
&lt;/h2&gt;

&lt;p&gt;A QR-authenticated inbox begins with a different requirement:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Our operators already use this TikTok inbox. How can its inbound messages reach our backend?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With UnifyPort, the flow uses the same general account lifecycle as other QR-authenticated messaging channels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create account
    ↓
Start QR authentication
    ↓
Poll authentication state
    ↓
User scans and confirms
    ↓
Account becomes connected
    ↓
Signed message events reach the webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account is created with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tiktok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"qrcode"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentication then starts through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/accounts/{account_id}/auth/qr/start
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client checks progress through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/accounts/{account_id}/auth/qr/check
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/accounts/{account_id}/auth
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important TikTok-specific detail is that the initial QR start response may not contain a QR URL.&lt;/p&gt;

&lt;p&gt;That should not automatically be treated as an error.&lt;/p&gt;

&lt;p&gt;Your interface must support an intermediate state in which authentication started successfully but the QR material is not available yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model QR authentication as a state machine
&lt;/h2&gt;

&lt;p&gt;Avoid implementing QR authentication as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startQrAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qrUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QR authentication failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That implementation incorrectly treats delayed QR material as failure.&lt;/p&gt;

&lt;p&gt;Use an explicit state model instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idle
  ↓
starting
  ↓
waiting_for_qr
  ↓
qr_available
  ↓
waiting_for_scan
  ↓
authenticated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow may also terminate in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expired
failed
cancelled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A polling loop can distinguish these outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForTikTokAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkQrAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qrUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;displayQrCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qrUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Authentication ended with status: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authentication polling timed out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the documented response fields from the API instead of assuming the example property names above match every provider implementation.&lt;/p&gt;

&lt;p&gt;The important design principle is that “QR URL not returned yet” and “authentication failed” are different states.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture difference
&lt;/h2&gt;

&lt;p&gt;The official API and QR-authenticated inbox also produce different system shapes.&lt;/p&gt;

&lt;h3&gt;
  
  
  TikTok-specific business integration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TikTok Business Account
        ↓
TikTok Business Messaging API
        ↓
TikTok-specific webhook adapter
        ↓
Application services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This model is a good fit when your product intentionally exposes TikTok-specific capabilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Normalized multi-channel intake
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TikTok inbox ─────┐
WhatsApp inbox ───┤
LINE inbox ───────┤
Zalo inbox ───────┼→ Normalized webhook → Shared queue
Telegram inbox ───┤
X inbox ──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This model is a good fit when the operational problem is receiving and routing messages from several platforms.&lt;/p&gt;

&lt;p&gt;It does not mean the platforms have identical features. It means the intake layer presents a shared event contract before provider-specific decisions are applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store inbound events before routing them
&lt;/h2&gt;

&lt;p&gt;Once authentication succeeds, incoming TikTok messages can be delivered as normalized &lt;code&gt;message.received&lt;/code&gt; events.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_2f9c1a4b7e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tiktok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acc_8c21d0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-07T12:34:56Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_778899"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_778899"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jordan Lee"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_3003"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hi, is this item still available?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sent_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-07T12:34:55Z"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message_received"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not send the message directly to business logic before storing it.&lt;/p&gt;

&lt;p&gt;Use this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verify signature
    ↓
Check event ID
    ↓
Store event
    ↓
Acknowledge delivery
    ↓
Route asynchronously
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This protects the receiver from duplicate deliveries, downstream outages and slow CRM operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the signed raw body
&lt;/h2&gt;

&lt;p&gt;Webhook verification must happen before the event is trusted.&lt;/p&gt;

&lt;p&gt;For UnifyPort webhook delivery, the receiver verifies &lt;code&gt;X-Device-Signature&lt;/code&gt; using the endpoint’s &lt;code&gt;signing_secret&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The signed input is constructed from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Device-Timestamp + "." + raw request body
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature uses HMAC-SHA256.&lt;/p&gt;

&lt;p&gt;A simplified Express route should preserve the raw request body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Device-Timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Device-Signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;signingSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNIFYPORT_SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;storeEventIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not parse the JSON and then recreate it for signature verification. Re-serialization may change the exact bytes.&lt;/p&gt;

&lt;p&gt;The verification function should also reject timestamps outside your accepted replay window and compare signatures using a timing-safe operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make event storage idempotent
&lt;/h2&gt;

&lt;p&gt;Webhook providers may retry delivery.&lt;/p&gt;

&lt;p&gt;Use the event’s &lt;code&gt;id&lt;/code&gt; as a unique key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;storeEventIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;occurredAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ignore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The precise database syntax will vary, but the invariant should remain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Processing the same event more than once must not create duplicate tickets, replies or notifications.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After storage, enqueue a separate routing job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize intake without hiding provider differences
&lt;/h2&gt;

&lt;p&gt;A shared event contract makes common routing easier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;routeInboundMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.received&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assignToQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common logic can handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;persistence;&lt;/li&gt;
&lt;li&gt;deduplication;&lt;/li&gt;
&lt;li&gt;assignment;&lt;/li&gt;
&lt;li&gt;CRM lookup;&lt;/li&gt;
&lt;li&gt;notification;&lt;/li&gt;
&lt;li&gt;SLA tracking;&lt;/li&gt;
&lt;li&gt;AI classification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Provider-specific behavior should remain explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tiktok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyTikTokPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;whatsapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyWhatsAppPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyDefaultPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normalization should reduce duplicated infrastructure. It should not pretend that every platform has the same reply rules, media support, session behavior or business features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the operational responsibilities
&lt;/h2&gt;

&lt;p&gt;The API surface is only part of the decision.&lt;/p&gt;

&lt;p&gt;You also need to compare what your team will operate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Official API&lt;/th&gt;
&lt;th&gt;QR-authenticated inbox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application authorization&lt;/td&gt;
&lt;td&gt;TikTok business integration&lt;/td&gt;
&lt;td&gt;UnifyPort API credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account eligibility&lt;/td&gt;
&lt;td&gt;Business Account requirements&lt;/td&gt;
&lt;td&gt;Existing supported TikTok account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session lifecycle&lt;/td&gt;
&lt;td&gt;Platform-managed authorization model&lt;/td&gt;
&lt;td&gt;QR authentication and possible re-authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook verification&lt;/td&gt;
&lt;td&gt;TikTok-specific contract&lt;/td&gt;
&lt;td&gt;UnifyPort signed webhook contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message normalization&lt;/td&gt;
&lt;td&gt;Build your own adapter&lt;/td&gt;
&lt;td&gt;Shared event envelope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-channel support&lt;/td&gt;
&lt;td&gt;Separate provider integrations&lt;/td&gt;
&lt;td&gt;Reusable intake pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature guarantees&lt;/td&gt;
&lt;td&gt;Official documented capabilities&lt;/td&gt;
&lt;td&gt;Provider support matrix and current adapter behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither path eliminates operational work. It changes the kind of work your team owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the official Business Messaging API when
&lt;/h2&gt;

&lt;p&gt;Prefer TikTok’s official integration when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TikTok Business Account identity is central to the product;&lt;/li&gt;
&lt;li&gt;you need official business-messaging capabilities;&lt;/li&gt;
&lt;li&gt;campaign attribution matters;&lt;/li&gt;
&lt;li&gt;platform-managed automation is required;&lt;/li&gt;
&lt;li&gt;your organization can complete the necessary reviews;&lt;/li&gt;
&lt;li&gt;TikTok-specific features justify a dedicated adapter;&lt;/li&gt;
&lt;li&gt;official program guarantees are more important than cross-channel uniformity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This path should be evaluated against TikTok’s current documentation and access requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose a QR-authenticated inbox when
&lt;/h2&gt;

&lt;p&gt;Consider the QR path when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an existing TikTok inbox already receives customer messages;&lt;/li&gt;
&lt;li&gt;the immediate need is inbound message collection;&lt;/li&gt;
&lt;li&gt;TikTok must enter the same queue as other messaging platforms;&lt;/li&gt;
&lt;li&gt;the backend benefits from one signed event contract;&lt;/li&gt;
&lt;li&gt;your team can manage session health and re-authentication;&lt;/li&gt;
&lt;li&gt;you do not depend on TikTok-native business features;&lt;/li&gt;
&lt;li&gt;the integration is operational rather than campaign-centric.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This path is especially useful when a support team wants to consolidate existing accounts without building an independent webhook adapter for every provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not confuse related TikTok APIs
&lt;/h2&gt;

&lt;p&gt;TikTok exposes several integration surfaces that may sound similar.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Business Messaging API;&lt;/li&gt;
&lt;li&gt;TikTok Shop Customer Service APIs;&lt;/li&gt;
&lt;li&gt;content publishing APIs;&lt;/li&gt;
&lt;li&gt;marketing and advertising APIs;&lt;/li&gt;
&lt;li&gt;QR-authenticated account connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They should not be treated as interchangeable.&lt;/p&gt;

&lt;p&gt;A TikTok Shop customer-support workflow may have different eligibility, data models and production requirements from a general Business Messaging integration.&lt;/p&gt;

&lt;p&gt;Before choosing an endpoint, identify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Account type
Product surface
Message source
Required operations
Region
Review requirements
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Decision checklist
&lt;/h2&gt;

&lt;p&gt;Before implementing either path, answer these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Which TikTok account type will authorize the integration?&lt;/li&gt;
&lt;li&gt;[ ] Does the product require official TikTok business features?&lt;/li&gt;
&lt;li&gt;[ ] Is the workflow TikTok-only or multi-channel?&lt;/li&gt;
&lt;li&gt;[ ] Does an operational TikTok inbox already exist?&lt;/li&gt;
&lt;li&gt;[ ] Can the team complete platform access and review requirements?&lt;/li&gt;
&lt;li&gt;[ ] Can the team operate QR session renewal safely?&lt;/li&gt;
&lt;li&gt;[ ] How will webhook signatures be verified?&lt;/li&gt;
&lt;li&gt;[ ] How will duplicate events be prevented?&lt;/li&gt;
&lt;li&gt;[ ] Will events be stored before downstream processing?&lt;/li&gt;
&lt;li&gt;[ ] Which provider-specific rules must remain outside the normalized layer?&lt;/li&gt;
&lt;li&gt;[ ] What happens when authorization expires?&lt;/li&gt;
&lt;li&gt;[ ] Which support matrix defines the features available today?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Do not select a TikTok DM integration by asking only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which endpoint lets me receive messages?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which identity, feature set and operating model should own this workflow?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Choose the official Business Messaging API when your product is built around TikTok Business Account capabilities.&lt;/p&gt;

&lt;p&gt;Choose a QR-authenticated inbox when an existing operational account needs to join a signed, multi-channel inbound queue.&lt;/p&gt;

&lt;p&gt;The right architecture depends less on whether both paths can deliver a message and more on what happens before authentication, after delivery, and when the connection needs ongoing maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://business-api.tiktok.com/portal/docs?id=1735712062490625" rel="noopener noreferrer"&gt;TikTok API for Business documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://business-api.tiktok.com/portal/bm-api/education-hub" rel="noopener noreferrer"&gt;TikTok Business Messaging API education hub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/provider-guide/provider-auth-tiktok/" rel="noopener noreferrer"&gt;UnifyPort TikTok authorization guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/authentication/check-qr-auth/" rel="noopener noreferrer"&gt;UnifyPort QR authentication check&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-delivery/" rel="noopener noreferrer"&gt;UnifyPort webhook delivery and verification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/blog/tiktok-qr-authorization-signed-webhook/" rel="noopener noreferrer"&gt;Connect TikTok to a signed webhook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tiktok</category>
      <category>api</category>
      <category>webhooks</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build a Reliable WhatsApp Group Join-Request Approval Queue with Node.js</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:04:30 +0000</pubDate>
      <link>https://dev.to/unifyport/build-a-reliable-whatsapp-group-join-request-approval-queue-with-nodejs-3oi2</link>
      <guid>https://dev.to/unifyport/build-a-reliable-whatsapp-group-join-request-approval-queue-with-nodejs-3oi2</guid>
      <description>&lt;p&gt;A WhatsApp group join request arrives.&lt;/p&gt;

&lt;p&gt;Your backend receives a webhook, checks a few rules, and immediately approves the user.&lt;/p&gt;

&lt;p&gt;That sounds convenient, but it creates a fragile moderation system.&lt;/p&gt;

&lt;p&gt;Webhooks can be delayed, retried, delivered out of order, or missed. A requester may also disappear from the pending list before your moderator acts.&lt;/p&gt;

&lt;p&gt;The safer architecture is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook signal
    ↓
Durable reconciliation job
    ↓
Fetch current pending requests
    ↓
Store and review
    ↓
Approve or reject exact requester IDs
    ↓
Reconcile again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key principle is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use the webhook to wake up the system. Use the list endpoint to determine the current state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article demonstrates that pattern with Node.js and the UnifyPort API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What WhatsApp still controls
&lt;/h2&gt;

&lt;p&gt;WhatsApp group approval remains a group-admin feature.&lt;/p&gt;

&lt;p&gt;When &lt;strong&gt;Approve new members&lt;/strong&gt; is enabled, an administrator must approve people who request to join. According to the WhatsApp Help Center, this setting is disabled by default.&lt;/p&gt;

&lt;p&gt;Your backend does not replace this permission model.&lt;/p&gt;

&lt;p&gt;The connected WhatsApp account still needs the appropriate group permissions. Automation only helps answer the operational questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who requested access?&lt;/li&gt;
&lt;li&gt;Which group are they trying to join?&lt;/li&gt;
&lt;li&gt;How did they arrive?&lt;/li&gt;
&lt;li&gt;Who reviewed the request?&lt;/li&gt;
&lt;li&gt;What decision was made?&lt;/li&gt;
&lt;li&gt;Has WhatsApp accepted the decision?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep permission enforcement in WhatsApp and moderation policy in your own application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three API operations
&lt;/h2&gt;

&lt;p&gt;A reliable approval queue uses three separate operations.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;group.join_request&lt;/code&gt; webhook&lt;/td&gt;
&lt;td&gt;Low-latency notification that the pending state may have changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List group join requests&lt;/td&gt;
&lt;td&gt;Retrieve the current set of pending requesters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update group join requests&lt;/td&gt;
&lt;td&gt;Approve or reject selected requester IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do not approve a user using only the data from the webhook.&lt;/p&gt;

&lt;p&gt;Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive and verify the webhook.&lt;/li&gt;
&lt;li&gt;Enqueue a reconciliation job.&lt;/li&gt;
&lt;li&gt;Fetch the current pending list.&lt;/li&gt;
&lt;li&gt;Store the returned requester IDs.&lt;/li&gt;
&lt;li&gt;Let a moderator or policy make the decision.&lt;/li&gt;
&lt;li&gt;Submit the selected IDs to the update endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Example webhook event
&lt;/h2&gt;

&lt;p&gt;A normalized WhatsApp group join-request event can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_gjr_5e1c8a3f9b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group.join_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"whatsapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acc_8c21d0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-02T05:10:30Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120363041234567890@g.us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requester"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8613912345678@lid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"request_method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invite_link"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group_join_request"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful fields include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt;: webhook event ID;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;account_id&lt;/code&gt;: connected WhatsApp account;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data.conversation.id&lt;/code&gt;: provider group ID;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data.requester.id&lt;/code&gt;: requester’s provider ID;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data.request_method&lt;/code&gt;: how the request was initiated, when available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the event ID so webhook retries remain idempotent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify before trusting the event
&lt;/h2&gt;

&lt;p&gt;Webhook signatures must be verified against the raw HTTP request body.&lt;/p&gt;

&lt;p&gt;Do not parse JSON, serialize it again, and then verify the reconstructed value. Even semantically identical JSON can produce different bytes.&lt;/p&gt;

&lt;p&gt;A simplified Express route might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifyWebhookSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group.join_request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleJoinRequestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep signature verification isolated and test it using the exact header and signing contract documented for your webhook provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the receiver fast and idempotent
&lt;/h2&gt;

&lt;p&gt;The webhook handler should not wait for a moderator decision.&lt;/p&gt;

&lt;p&gt;Its job is to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the signature.&lt;/li&gt;
&lt;li&gt;Check whether the event was already processed.&lt;/li&gt;
&lt;li&gt;Store minimal event information.&lt;/li&gt;
&lt;li&gt;Enqueue a reconciliation job.&lt;/li&gt;
&lt;li&gt;Return a successful response quickly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleJoinRequestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertWebhookEventIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;occurredAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueReconciliation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique &lt;code&gt;eventId&lt;/code&gt; prevents duplicate webhook deliveries from creating duplicate work.&lt;/p&gt;

&lt;p&gt;The reconciliation key prevents several events for the same group from producing an unnecessary burst of identical jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not use an in-memory queue in production
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Map&lt;/code&gt; or array is useful for a local example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not a reliable production queue.&lt;/p&gt;

&lt;p&gt;The state disappears when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the process restarts;&lt;/li&gt;
&lt;li&gt;the deployment replaces the instance;&lt;/li&gt;
&lt;li&gt;the application crashes;&lt;/li&gt;
&lt;li&gt;another instance receives the next webhook;&lt;/li&gt;
&lt;li&gt;the platform scales the service horizontally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use durable infrastructure such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a database-backed jobs table;&lt;/li&gt;
&lt;li&gt;Redis with a persistent queue library;&lt;/li&gt;
&lt;li&gt;Amazon SQS;&lt;/li&gt;
&lt;li&gt;Google Cloud Tasks;&lt;/li&gt;
&lt;li&gt;RabbitMQ;&lt;/li&gt;
&lt;li&gt;another queue with retry and visibility semantics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important requirement is that acknowledging the webhook must not discard the reconciliation work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetch the current pending list
&lt;/h2&gt;

&lt;p&gt;When the worker runs, fetch the group’s current join requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.unifyport.ai/v1/accounts/acc_8c21d0/groups/join-requests?group_id=120363041234567890%40g.us"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;group_id&lt;/code&gt; is passed as a query parameter and should be URL-encoded.&lt;/p&gt;

&lt;p&gt;A Node.js worker can build the request safely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listGroupJoinRequests&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.unifyport.ai/v1/accounts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/groups/join-requests?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Api-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unable to list join requests: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;UNIFYPORT_API_KEY&lt;/code&gt; on the server. Never expose it to browser code or commit it to the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist requesters before reviewing them
&lt;/h2&gt;

&lt;p&gt;The list response is the reliable source for pending requests.&lt;/p&gt;

&lt;p&gt;Persist each returned requester using a stable uniqueness rule such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;account_id + group_id + member_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minimal record could contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;moderationRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acc_8c21d0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;120363041234567890@g.us&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;8613912345678@lid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invite_link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-09-02T05:10:30Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;decisionBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;decisionAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not use a display name as the primary identifier. Display names are not necessarily unique or stable.&lt;/p&gt;

&lt;p&gt;Use the &lt;code&gt;id&lt;/code&gt; returned by the list endpoint as the value that later enters &lt;code&gt;member_ids&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile with upserts
&lt;/h2&gt;

&lt;p&gt;A worker can upsert every currently pending request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconcileGroupJoinRequests&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listGroupJoinRequests&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertModerationRequest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;requestedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requested_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markMissingRequestsForReview&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currentMemberIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be careful with requests missing from a later list.&lt;/p&gt;

&lt;p&gt;Their absence might mean they were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;approved elsewhere;&lt;/li&gt;
&lt;li&gt;rejected elsewhere;&lt;/li&gt;
&lt;li&gt;cancelled;&lt;/li&gt;
&lt;li&gt;expired;&lt;/li&gt;
&lt;li&gt;already processed by another moderator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not automatically label every missing record as rejected unless the provider contract proves that interpretation.&lt;/p&gt;

&lt;p&gt;A neutral state such as &lt;code&gt;no_longer_pending&lt;/code&gt; is safer until you have more evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the moderator workflow
&lt;/h2&gt;

&lt;p&gt;The moderator UI should display enough information to make a decision without exposing unnecessary personal data.&lt;/p&gt;

&lt;p&gt;Useful fields include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;group;&lt;/li&gt;
&lt;li&gt;requester ID;&lt;/li&gt;
&lt;li&gt;phone number, if returned and necessary;&lt;/li&gt;
&lt;li&gt;request time;&lt;/li&gt;
&lt;li&gt;request method;&lt;/li&gt;
&lt;li&gt;current status;&lt;/li&gt;
&lt;li&gt;assigned moderator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple state machine could be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending
   ├── approving
   │      ├── approved
   │      └── pending
   └── rejecting
          ├── rejected
          └── pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intermediate states prevent two moderators from acting on the same request simultaneously.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;claimDecision&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;moderatorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moderationRequests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approving&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejecting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;decisionBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;moderatorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Continue only if exactly one record was updated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approve or reject exact requester IDs
&lt;/h2&gt;

&lt;p&gt;To approve a requester:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.unifyport.ai/v1/accounts/acc_8c21d0/groups/join-requests/update"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "group_id": "120363041234567890@g.us",
    "action": "approve",
    "member_ids": [
      "8613912345678@lid"
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To reject the request, change the action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"group_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120363041234567890@g.us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"member_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"8613912345678@lid"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value in &lt;code&gt;member_ids&lt;/code&gt; must come from the list endpoint.&lt;/p&gt;

&lt;p&gt;Do not substitute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a display name;&lt;/li&gt;
&lt;li&gt;a guessed phone-number format;&lt;/li&gt;
&lt;li&gt;the group conversation ID;&lt;/li&gt;
&lt;li&gt;an identifier copied from an unrelated event.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap the decision in a service
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateJoinRequests&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;memberIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action must be approve or reject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;memberIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;memberIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;memberIds must contain at least one requester ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.unifyport.ai/v1/accounts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/groups/join-requests/update`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Api-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;member_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;memberIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unable to update join requests: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call the external API only after your application has claimed the pending decision.&lt;/p&gt;

&lt;p&gt;After success, save an audit record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveModerationDecision&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;memberIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;moderatorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;decidedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never store the API key in the audit record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile after the decision
&lt;/h2&gt;

&lt;p&gt;A successful HTTP response confirms that the action request was accepted according to the API contract.&lt;/p&gt;

&lt;p&gt;Your local queue should still reconcile the group afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateJoinRequests&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;memberIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueReconciliation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next list operation confirms which requests remain pending.&lt;/p&gt;

&lt;p&gt;This catches situations where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;another moderator acted first;&lt;/li&gt;
&lt;li&gt;a requester cancelled;&lt;/li&gt;
&lt;li&gt;the group state changed;&lt;/li&gt;
&lt;li&gt;a batch contained stale requester IDs;&lt;/li&gt;
&lt;li&gt;the local view was outdated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Webhook plus periodic polling
&lt;/h2&gt;

&lt;p&gt;A webhook-triggered reconciliation gives low latency, but periodic polling closes delivery gaps.&lt;/p&gt;

&lt;p&gt;A practical strategy is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook received → reconcile that group immediately
Every few minutes → reconcile active groups
Before moderation → refresh the selected group
After moderation → reconcile again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The polling interval depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request volume;&lt;/li&gt;
&lt;li&gt;acceptable moderation delay;&lt;/li&gt;
&lt;li&gt;API limits;&lt;/li&gt;
&lt;li&gt;number of active groups;&lt;/li&gt;
&lt;li&gt;operational cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to poll as frequently as possible. The goal is to ensure webhook delivery is not your only path to correct state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep policy outside the transport layer
&lt;/h2&gt;

&lt;p&gt;The webhook receiver should not contain business rules such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requesterPhone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+86&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transport code should verify, store, and enqueue.&lt;/p&gt;

&lt;p&gt;Moderation policy belongs in a separate service where it can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reviewed;&lt;/li&gt;
&lt;li&gt;tested;&lt;/li&gt;
&lt;li&gt;audited;&lt;/li&gt;
&lt;li&gt;changed without touching signature verification;&lt;/li&gt;
&lt;li&gt;overridden by a human moderator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recommendation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;evaluateJoinRequest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;requestMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveRecommendation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;recommendation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if the system generates an automated recommendation, keep the final action and its evidence auditable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure scenarios to plan for
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Duplicate webhooks
&lt;/h3&gt;

&lt;p&gt;Use the webhook event ID as an idempotency key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missed webhooks
&lt;/h3&gt;

&lt;p&gt;Run periodic list reconciliation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multiple events for one group
&lt;/h3&gt;

&lt;p&gt;Deduplicate queued work using &lt;code&gt;account_id + group_id&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two moderators act simultaneously
&lt;/h3&gt;

&lt;p&gt;Use an atomic status transition from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;approving&lt;/code&gt; or &lt;code&gt;rejecting&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Request disappears before review
&lt;/h3&gt;

&lt;p&gt;Refresh the list and mark the local record &lt;code&gt;no_longer_pending&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worker crashes after the API request
&lt;/h3&gt;

&lt;p&gt;Reconcile the list before retrying the external action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unauthorized account
&lt;/h3&gt;

&lt;p&gt;Confirm the connected account has the required WhatsApp group-admin permissions. Automation cannot create permissions the account does not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and privacy checklist
&lt;/h2&gt;

&lt;p&gt;Before deploying the queue, confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Webhook signatures are verified against the raw body.&lt;/li&gt;
&lt;li&gt;[ ] Webhook event IDs are stored idempotently.&lt;/li&gt;
&lt;li&gt;[ ] Reconciliation jobs use durable storage.&lt;/li&gt;
&lt;li&gt;[ ] API keys remain server-side.&lt;/li&gt;
&lt;li&gt;[ ] Logs do not contain API keys.&lt;/li&gt;
&lt;li&gt;[ ] Moderator actions record who made the decision.&lt;/li&gt;
&lt;li&gt;[ ] Only necessary requester data is displayed.&lt;/li&gt;
&lt;li&gt;[ ] Requester IDs come from the list endpoint.&lt;/li&gt;
&lt;li&gt;[ ] Missing requests are not automatically classified as rejected.&lt;/li&gt;
&lt;li&gt;[ ] WhatsApp group permissions are checked operationally.&lt;/li&gt;
&lt;li&gt;[ ] Periodic polling covers missed webhook delivery.&lt;/li&gt;
&lt;li&gt;[ ] Post-decision reconciliation is enabled.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A webhook is an excellent wake-up signal, but it should not be your entire moderation database.&lt;/p&gt;

&lt;p&gt;Build the approval queue around this sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verify
  ↓
Store
  ↓
Reconcile
  ↓
Review
  ↓
Approve or reject
  ↓
Reconcile again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This design remains reliable when webhooks are retried, events arrive out of order, moderators work concurrently, or the pending WhatsApp state changes outside your application.&lt;/p&gt;

&lt;p&gt;Use WhatsApp’s group setting as the permission boundary, the pending-list endpoint as the current state, and your own database as the moderation and audit record.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://faq.whatsapp.com/902091421605313" rel="noopener noreferrer"&gt;WhatsApp: How to approve new members as a group admin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/event-group-join_request/" rel="noopener noreferrer"&gt;UnifyPort: Group join-request event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/groups/list-group-join-requests/" rel="noopener noreferrer"&gt;UnifyPort: List group join requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/groups/update-group-join-requests/" rel="noopener noreferrer"&gt;UnifyPort: Approve or reject group join requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-delivery/" rel="noopener noreferrer"&gt;UnifyPort: Webhook delivery and verification&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>node</category>
      <category>ai</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Choosing the Right LINE Message UI: Confirm, Buttons, Quick Replies, Carousel, or Flex?</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Tue, 25 Aug 2026 07:53:44 +0000</pubDate>
      <link>https://dev.to/unifyport/choosing-the-right-line-message-ui-confirm-buttons-quick-replies-carousel-or-flex-l5b</link>
      <guid>https://dev.to/unifyport/choosing-the-right-line-message-ui-confirm-buttons-quick-replies-carousel-or-flex-l5b</guid>
      <description>&lt;p&gt;You are building a LINE customer flow and start with a confirm template.&lt;/p&gt;

&lt;p&gt;Then the requirements grow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Confirm
Cancel
Change address
Contact support
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, the problem is no longer “How can I add more buttons to a confirm template?”&lt;/p&gt;

&lt;p&gt;The real question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which LINE message type matches the decision the user needs to make?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A LINE confirm template is intentionally limited to two actions. It is designed for one binary decision—not as a compact menu.&lt;/p&gt;

&lt;p&gt;When the flow needs more choices, move to a buttons template, quick replies, a carousel, or a Flex Message instead of forcing the confirm component beyond its intended shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;LINE message surface&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Main interaction limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Confirm template&lt;/td&gt;
&lt;td&gt;One binary decision&lt;/td&gt;
&lt;td&gt;Two actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buttons template&lt;/td&gt;
&lt;td&gt;A compact card with several primary actions&lt;/td&gt;
&lt;td&gt;Up to four actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick replies&lt;/td&gt;
&lt;td&gt;A temporary next-step menu&lt;/td&gt;
&lt;td&gt;Up to 13 quick reply buttons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carousel template&lt;/td&gt;
&lt;td&gt;Browsing repeated items&lt;/td&gt;
&lt;td&gt;Multiple structured columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flex Message&lt;/td&gt;
&lt;td&gt;Custom layouts and visual hierarchy&lt;/td&gt;
&lt;td&gt;Flexible JSON-based layout&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A useful rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Two choices      → Confirm
Three or four    → Buttons
Five to thirteen → Quick replies
Repeated items   → Carousel
Custom layout    → Flex Message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number of choices is not the only factor, but it is a good first filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a confirm template is designed to do
&lt;/h2&gt;

&lt;p&gt;A confirm template presents text followed by exactly two actions.&lt;/p&gt;

&lt;p&gt;Typical examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confirm or cancel&lt;/li&gt;
&lt;li&gt;Accept or decline&lt;/li&gt;
&lt;li&gt;Approve or reject&lt;/li&gt;
&lt;li&gt;Use this address or edit it&lt;/li&gt;
&lt;li&gt;Contact support or continue browsing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified request looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"altText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Please confirm your booking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirm your booking for tomorrow at 10:00?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"action=confirm_booking"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"action=cancel_booking"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two-action limit is not an arbitrary inconvenience. It protects the interaction model.&lt;/p&gt;

&lt;p&gt;A confirm card asks one focused question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do you want to proceed?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the card is trying to explain several unrelated paths, it is no longer a confirmation step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a buttons template for three or four actions
&lt;/h2&gt;

&lt;p&gt;A buttons template is a separate LINE template type.&lt;/p&gt;

&lt;p&gt;Choose it when users need a few visible actions on one compact card, such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;View order
Track delivery
Change address
Contact support
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike a confirm template, the buttons template can support up to four action objects.&lt;/p&gt;

&lt;p&gt;It is a good fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;there are three or four important choices;&lt;/li&gt;
&lt;li&gt;all choices belong to the same context;&lt;/li&gt;
&lt;li&gt;the actions should remain visible on one card;&lt;/li&gt;
&lt;li&gt;the content benefits from a title, text, or image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not use four buttons merely because the component permits them. If one action is clearly primary and the others are secondary, consider simplifying the card or moving secondary options to the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use quick replies for temporary menus
&lt;/h2&gt;

&lt;p&gt;Quick replies are useful when users need several immediate choices without a large permanent card.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where should we route your request?

Billing
Delivery
Returns
Technical support
Sales
Other
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LINE supports up to 13 quick reply buttons.&lt;/p&gt;

&lt;p&gt;That makes quick replies appropriate for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;short category lists;&lt;/li&gt;
&lt;li&gt;selecting the next workflow step;&lt;/li&gt;
&lt;li&gt;choosing a language;&lt;/li&gt;
&lt;li&gt;selecting a date or location;&lt;/li&gt;
&lt;li&gt;answering a question immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, quick replies are temporary interface elements. They can disappear as the conversation moves forward.&lt;/p&gt;

&lt;p&gt;Do not use them for actions users must be able to return to later.&lt;/p&gt;

&lt;p&gt;If an option needs to remain available, consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a buttons template;&lt;/li&gt;
&lt;li&gt;a rich menu;&lt;/li&gt;
&lt;li&gt;a carousel;&lt;/li&gt;
&lt;li&gt;a Flex Message;&lt;/li&gt;
&lt;li&gt;sending the menu again when needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use a carousel for repeated objects
&lt;/h2&gt;

&lt;p&gt;A carousel is better when users are selecting between repeated items with the same structure.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;products;&lt;/li&gt;
&lt;li&gt;subscription plans;&lt;/li&gt;
&lt;li&gt;store locations;&lt;/li&gt;
&lt;li&gt;appointment slots;&lt;/li&gt;
&lt;li&gt;support topics;&lt;/li&gt;
&lt;li&gt;delivery options.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of presenting a flat list of unrelated buttons, each carousel column can represent one item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product A
Image
Price
View
Buy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product B
Image
Price
View
Buy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product C
Image
Price
View
Buy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A carousel works well when users need to compare multiple objects.&lt;/p&gt;

&lt;p&gt;It is a poor fit when the choices are simple workflow actions such as “confirm” and “cancel.” In that case, the additional visual structure only adds friction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Flex Messages for custom hierarchy
&lt;/h2&gt;

&lt;p&gt;A Flex Message is the most adaptable option.&lt;/p&gt;

&lt;p&gt;Use it when the interaction depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;custom branding;&lt;/li&gt;
&lt;li&gt;more complex visual hierarchy;&lt;/li&gt;
&lt;li&gt;multiple content sections;&lt;/li&gt;
&lt;li&gt;responsive layout;&lt;/li&gt;
&lt;li&gt;structured summaries;&lt;/li&gt;
&lt;li&gt;information-dense cards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, an order confirmation might need to display:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order number
Items
Delivery address
Payment status
Total
Primary action
Secondary action
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Flex Message can express that hierarchy more clearly than a template with a fixed structure.&lt;/p&gt;

&lt;p&gt;The trade-off is complexity.&lt;/p&gt;

&lt;p&gt;Flex Messages require:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more JSON;&lt;/li&gt;
&lt;li&gt;more layout decisions;&lt;/li&gt;
&lt;li&gt;more rendering tests;&lt;/li&gt;
&lt;li&gt;checking different screen sizes;&lt;/li&gt;
&lt;li&gt;validating fallback and accessibility text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not choose Flex merely because it is flexible. Use it when the product experience actually needs a custom layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision function
&lt;/h2&gt;

&lt;p&gt;You can model the initial choice in application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chooseLineSurface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;actionCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isBinaryDecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isTemporary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;representsRepeatedItems&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;needsCustomLayout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isBinaryDecision&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;actionCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;representsRepeatedItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;carousel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needsCustomLayout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actionCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isTemporary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;buttons&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actionCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;isTemporary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quickReplies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redesignRequired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a replacement for product judgment, but it makes the component boundaries explicit.&lt;/p&gt;

&lt;p&gt;If the function returns &lt;code&gt;redesignRequired&lt;/code&gt;, splitting the interaction into multiple steps is usually better than placing every possible action in one message.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete decision tree
&lt;/h2&gt;

&lt;p&gt;Ask these questions in order.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Is this one binary decision?
&lt;/h3&gt;

&lt;p&gt;Examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Yes / No
Accept / Decline
Confirm / Cancel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a confirm template.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Are there three or four primary actions?
&lt;/h3&gt;

&lt;p&gt;Use a buttons template.&lt;/p&gt;

&lt;p&gt;Check whether all actions actually belong to the same card. If not, simplify the flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Are there several temporary next steps?
&lt;/h3&gt;

&lt;p&gt;Use quick replies.&lt;/p&gt;

&lt;p&gt;Design the workflow assuming those buttons may no longer be visible after the conversation continues.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Is the user comparing repeated objects?
&lt;/h3&gt;

&lt;p&gt;Use a carousel.&lt;/p&gt;

&lt;p&gt;Keep the column structure consistent so users can compare options easily.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Does the experience need a custom visual hierarchy?
&lt;/h3&gt;

&lt;p&gt;Use a Flex Message.&lt;/p&gt;

&lt;p&gt;Test the final JSON on different devices rather than assuming one preview represents every client.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Is the real problem receiving and routing the reply?
&lt;/h3&gt;

&lt;p&gt;That is a separate architectural concern.&lt;/p&gt;

&lt;p&gt;Use the official LINE Messaging API to render LINE-native UI. Then process the user’s reply through your inbound message pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate outbound presentation from inbound handling
&lt;/h2&gt;

&lt;p&gt;The component used to send a message and the system used to process the response do not need to be the same layer.&lt;/p&gt;

&lt;p&gt;A clean architecture separates them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Official LINE Messaging API
        ↓
Render confirm, buttons, carousel, or Flex UI
        ↓
User taps or sends a reply
        ↓
Inbound webhook
        ↓
Queue, CRM, support system, or automation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters for multi-channel systems.&lt;/p&gt;

&lt;p&gt;Your application may use LINE-native components for outbound presentation while routing incoming LINE replies through the same backend that handles WhatsApp, Telegram, Zalo, TikTok, and X.&lt;/p&gt;

&lt;p&gt;UnifyPort fits this inbound side. It does not render native LINE confirm templates, buttons templates, carousel templates, or Flex Messages.&lt;/p&gt;

&lt;p&gt;An inbound LINE event can use a normalized envelope such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"evt_01j7lineconfirm8p7m4w6n2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acct_line_support_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-25T09:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_line_4281"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirm"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conv_line_2841"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_line_73"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend can then route the event according to provider and reply content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleMessageReceived&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.received&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;routeInboundMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same handler can accept events from multiple providers without pretending that every provider offers identical outbound UI components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common design mistakes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Forcing a third action into a confirm flow
&lt;/h3&gt;

&lt;p&gt;If a binary decision suddenly needs a third option, review the product requirement.&lt;/p&gt;

&lt;p&gt;The third option may indicate that the interaction is actually a menu, not a confirmation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using quick replies for permanent navigation
&lt;/h3&gt;

&lt;p&gt;Quick replies are temporary. Important navigation should use a surface designed to remain accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Flex for every message
&lt;/h3&gt;

&lt;p&gt;Flex provides control, but it also increases implementation and testing cost. Fixed templates are often better for simple interactions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treating all channels as visually identical
&lt;/h3&gt;

&lt;p&gt;A normalized inbound event does not mean LINE, WhatsApp, Telegram, and other providers support the same outbound UI.&lt;/p&gt;

&lt;p&gt;Keep provider capabilities explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combining outbound UI and inbound routing decisions
&lt;/h3&gt;

&lt;p&gt;Choosing a LINE confirm template does not determine how your backend should store, assign, or route the response.&lt;/p&gt;

&lt;p&gt;Design those layers separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;p&gt;Before selecting a LINE message surface, confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] A confirm template contains one binary decision.&lt;/li&gt;
&lt;li&gt;[ ] The confirm template has exactly two actions.&lt;/li&gt;
&lt;li&gt;[ ] Three or four visible actions use a buttons template.&lt;/li&gt;
&lt;li&gt;[ ] Temporary menus use quick replies.&lt;/li&gt;
&lt;li&gt;[ ] Repeated items use a carousel.&lt;/li&gt;
&lt;li&gt;[ ] Custom visual hierarchy uses a Flex Message.&lt;/li&gt;
&lt;li&gt;[ ] Flex layouts are tested across devices.&lt;/li&gt;
&lt;li&gt;[ ] Important actions do not depend on temporary quick replies.&lt;/li&gt;
&lt;li&gt;[ ] Official LINE APIs handle LINE-native presentation.&lt;/li&gt;
&lt;li&gt;[ ] Inbound replies are processed by a separate webhook pipeline.&lt;/li&gt;
&lt;li&gt;[ ] Other providers are not assumed to support the same UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Do not choose a LINE message component by asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How can I fit all these actions into one template?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What kind of decision is the user making?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Confirm template → one binary decision
Buttons template → a few primary actions
Quick replies    → temporary next steps
Carousel         → repeated items
Flex Message     → custom visual hierarchy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The best component is the one that makes the next decision obvious—not the one that can technically contain the most buttons.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/message-types" rel="noopener noreferrer"&gt;LINE message types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/reference/messaging-api/nojs" rel="noopener noreferrer"&gt;LINE Messaging API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/using-quick-reply" rel="noopener noreferrer"&gt;Using quick replies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/flex-message-elements" rel="noopener noreferrer"&gt;Flex Message elements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-events/" rel="noopener noreferrer"&gt;UnifyPort webhook event reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/provider-guide/provider-message-support/" rel="noopener noreferrer"&gt;UnifyPort provider message support&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>Building Reliable WhatsApp Read/Unread Sync for a Shared Inbox</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:36:29 +0000</pubDate>
      <link>https://dev.to/unifyport/building-reliable-whatsapp-readunread-sync-for-a-shared-inbox-pad</link>
      <guid>https://dev.to/unifyport/building-reliable-whatsapp-readunread-sync-for-a-shared-inbox-pad</guid>
      <description>&lt;p&gt;A shared inbox receives a new WhatsApp message.&lt;/p&gt;

&lt;p&gt;Should the conversation immediately become “read”?&lt;/p&gt;

&lt;p&gt;Usually, no.&lt;/p&gt;

&lt;p&gt;Receiving a webhook only proves that your system received an event. It does not prove that an agent accepted the conversation, understood the request, or completed the work.&lt;/p&gt;

&lt;p&gt;A reliable shared inbox must keep three different kinds of state separate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your application’s queue state.&lt;/li&gt;
&lt;li&gt;The connected WhatsApp account’s read/unread state.&lt;/li&gt;
&lt;li&gt;Message-level read receipts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Combining these concepts can make unattended conversations disappear from the queue or create synchronization loops between your application and WhatsApp.&lt;/p&gt;

&lt;p&gt;This article uses the UnifyPort API to demonstrate a safer design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three meanings of “read”
&lt;/h2&gt;

&lt;p&gt;The word “read” can refer to three different things in a messaging system.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Local queue state
&lt;/h3&gt;

&lt;p&gt;This is the workflow state stored by your application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new
assigned
waiting
resolved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your database should also know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which agent owns the conversation;&lt;/li&gt;
&lt;li&gt;when it was accepted;&lt;/li&gt;
&lt;li&gt;why it was reopened;&lt;/li&gt;
&lt;li&gt;whether a response is still required;&lt;/li&gt;
&lt;li&gt;when the next follow-up is due.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is your support system’s source of truth.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. WhatsApp conversation state
&lt;/h3&gt;

&lt;p&gt;The connected WhatsApp account also maintains a chat-list state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read
unread
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing this state affects how the conversation appears in the connected account.&lt;/p&gt;

&lt;p&gt;It does not replace your application’s assignment and resolution model.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Message read receipts
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;message.read&lt;/code&gt; event describes a recipient reading one or more messages sent through the account.&lt;/p&gt;

&lt;p&gt;That is different from an agent opening an inbound support ticket.&lt;/p&gt;

&lt;p&gt;The three states may influence one another, but they should not be stored as one Boolean value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define an explicit synchronization policy
&lt;/h2&gt;

&lt;p&gt;Do not mark every conversation as read as soon as &lt;code&gt;message.received&lt;/code&gt; reaches your webhook.&lt;/p&gt;

&lt;p&gt;That policy can make an unattended queue look healthy.&lt;/p&gt;

&lt;p&gt;Instead, connect provider state to meaningful team actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team action&lt;/th&gt;
&lt;th&gt;Local queue state&lt;/th&gt;
&lt;th&gt;WhatsApp action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inbound message stored&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent accepts the conversation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;assigned&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optionally mark through the accepted message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent resolves the conversation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolved&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mark the conversation read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent requests follow-up&lt;/td&gt;
&lt;td&gt;&lt;code&gt;waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mark the conversation unread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automation fails before assignment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This prevents webhook retries, background previews, and browser refreshes from clearing work accidentally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mark a WhatsApp conversation as read
&lt;/h2&gt;

&lt;p&gt;To mark an entire conversation as read, call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/accounts/{account_id}/conversations/read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass the provider conversation identifier in the JSON body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.unifyport.ai/v1/accounts/&lt;/span&gt;&lt;span class="nv"&gt;$UNIFYPORT_ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;/conversations/read"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "conversation_id": "8613912345678@s.whatsapp.net"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;conversation_id&lt;/code&gt; belongs in the body instead of the URL because provider identifiers may contain characters such as &lt;code&gt;@&lt;/code&gt; and &lt;code&gt;:&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mark through a specific message
&lt;/h2&gt;

&lt;p&gt;For a message-level WhatsApp receipt, include both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;up_to_message_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;up_to_message_sender_id&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conversation_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120363041234567890@g.us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"up_to_message_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CURRENT-MESSAGE-ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"up_to_message_sender_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8613912345678@lid"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These two fields form a pair.&lt;/p&gt;

&lt;p&gt;Sending only one of them returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 invalid_request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy all three identifiers from the same verified &lt;code&gt;message.received&lt;/code&gt; event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a group conversation, do not derive the sender ID from the conversation ID. Use the matching &lt;code&gt;event.data.sender.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you do not need a message-specific receipt, omit both &lt;code&gt;up_to_message_*&lt;/code&gt; fields and mark the whole conversation as read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a small read-state helper
&lt;/h2&gt;

&lt;p&gt;A helper can enforce the field-pair rule before making the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.unifyport.ai/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setWhatsAppReadState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;unread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unread&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unread&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;unread&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.id and message.senderId must be supplied together&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;up_to_message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;up_to_message_sender_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiBase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/accounts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/conversations/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Api-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown_error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the API key on the server. Do not call this endpoint directly from browser code.&lt;/p&gt;

&lt;p&gt;After verifying and storing an inbound event, an agent-acceptance workflow could call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setWhatsAppReadState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unread&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mark a conversation unread
&lt;/h2&gt;

&lt;p&gt;When an agent intentionally reopens a conversation, call the unread action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/accounts/{account_id}/conversations/unread
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the same helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setWhatsAppReadState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unread&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unread action only requires &lt;code&gt;conversation_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not use the provider’s unread state as your only reminder mechanism. Store the follow-up owner, reason, and due time in your own database.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveFollowUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Waiting for customer documents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dueAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextBusinessDay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setWhatsAppReadState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unread&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the provider action fails, the local follow-up record still preserves the work that must be completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoid synchronization feedback loops
&lt;/h2&gt;

&lt;p&gt;When your application changes conversation state, a corresponding &lt;code&gt;conversation.updated&lt;/code&gt; event may arrive at your webhook.&lt;/p&gt;

&lt;p&gt;A naive implementation can produce a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application marks conversation read
        ↓
conversation.updated arrives
        ↓
Webhook handler marks conversation read again
        ↓
Another update arrives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat &lt;code&gt;conversation.updated&lt;/code&gt; as a reconciliation signal, not a command to repeat the same action.&lt;/p&gt;

&lt;p&gt;A safer workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Verify the webhook signature
2. Store the event idempotently
3. Update the local ticket state
4. Record the intended provider-state operation
5. Call the read/unread endpoint
6. Record success after the API confirms it
7. Use conversation.updated as confirmation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An operation record could contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestedState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent_acceptance&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;conversation.updated&lt;/code&gt; arrives, compare it with the pending operation.&lt;/p&gt;

&lt;p&gt;If it matches, confirm the operation instead of issuing the API request again.&lt;/p&gt;

&lt;p&gt;If there is no matching local operation, the state may have changed from the connected WhatsApp account or another system. Reconcile it according to your product policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store webhook events idempotently
&lt;/h2&gt;

&lt;p&gt;Webhook delivery can be retried.&lt;/p&gt;

&lt;p&gt;Before updating queue state, ensure the event has not already been processed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findProcessedEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateConversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact database implementation will vary, but the invariant should remain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Processing the same webhook more than once must not create additional state transitions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Also verify the webhook signature against the raw request body before trusting any event fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep local state authoritative
&lt;/h2&gt;

&lt;p&gt;Provider read state is useful for synchronization and operator visibility, but it is not a complete support database.&lt;/p&gt;

&lt;p&gt;The provider does not know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which agent accepted the ticket;&lt;/li&gt;
&lt;li&gt;whether the issue was resolved;&lt;/li&gt;
&lt;li&gt;why the conversation was reopened;&lt;/li&gt;
&lt;li&gt;whether a service-level deadline is approaching;&lt;/li&gt;
&lt;li&gt;which internal team owns the next action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical local model might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conversation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local-conversation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;providerConversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;8613912345678@s.whatsapp.net&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;queueState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assigned&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;providerReadState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;assignedAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;followUpAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lastProcessedEventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event-123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;providerReadState&lt;/code&gt; is a projection of WhatsApp state. It does not determine whether the support task is finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle unsupported providers correctly
&lt;/h2&gt;

&lt;p&gt;Read and unread conversation actions are currently supported for WhatsApp.&lt;/p&gt;

&lt;p&gt;An unsupported provider/action combination returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;501 unsupported_by_provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not treat this response as a temporary network failure. Retrying the same unsupported operation will not make it succeed.&lt;/p&gt;

&lt;p&gt;For a multi-channel inbox, check capabilities before displaying the control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canChangeProviderReadState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;whatsapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the provider is unsupported:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hide or disable the provider-state control;&lt;/li&gt;
&lt;li&gt;keep the local queue workflow available;&lt;/li&gt;
&lt;li&gt;explain that the action only affects internal state;&lt;/li&gt;
&lt;li&gt;do not continuously retry &lt;code&gt;501 unsupported_by_provider&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A unified API route does not imply that every provider implements every action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;p&gt;Before enabling read/unread synchronization, confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Queue state and provider read state are stored separately.&lt;/li&gt;
&lt;li&gt;[ ] Receiving &lt;code&gt;message.received&lt;/code&gt; does not automatically clear work.&lt;/li&gt;
&lt;li&gt;[ ] Webhook signatures are verified using the raw request body.&lt;/li&gt;
&lt;li&gt;[ ] Webhook processing is idempotent.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;up_to_message_id&lt;/code&gt; and &lt;code&gt;up_to_message_sender_id&lt;/code&gt; are always sent together.&lt;/li&gt;
&lt;li&gt;[ ] Group sender IDs come from &lt;code&gt;event.data.sender.id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Locally initiated operations are recorded.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;conversation.updated&lt;/code&gt; is used for reconciliation.&lt;/li&gt;
&lt;li&gt;[ ] Follow-up ownership and reasons are stored locally.&lt;/li&gt;
&lt;li&gt;[ ] Unsupported providers do not receive repeated read/unread requests.&lt;/li&gt;
&lt;li&gt;[ ] API keys remain on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A reliable shared inbox should not reduce every kind of read state to one Boolean value.&lt;/p&gt;

&lt;p&gt;Keep these concepts separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Local queue state
Provider conversation state
Message receipt state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the WhatsApp read state only when a meaningful workflow action occurs, such as agent acceptance, resolution, or an intentional follow-up.&lt;/p&gt;

&lt;p&gt;Then treat provider events as reconciliation signals while keeping assignment, ownership, and resolution state in your own database.&lt;/p&gt;

&lt;p&gt;That separation prevents unattended conversations from disappearing and keeps your shared inbox reliable when webhooks are retried or multiple systems update the same chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/conversations/mark-conversation-read/" rel="noopener noreferrer"&gt;Mark a conversation as read&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/conversations/mark-conversation-unread/" rel="noopener noreferrer"&gt;Mark a conversation as unread&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/provider-guide/provider-actions-support/" rel="noopener noreferrer"&gt;Provider action support matrix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/receiving-events/webhook-events/" rel="noopener noreferrer"&gt;Webhook event types and payloads&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/blog/webhook-hmac-replay-protection-retries/" rel="noopener noreferrer"&gt;Webhook HMAC, replay protection, and retries&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>whatsap</category>
      <category>javascript</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Zero-Downtime API Key Rotation: Why You Shouldn’t Call Rotate First</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Mon, 17 Aug 2026 06:57:22 +0000</pubDate>
      <link>https://dev.to/unifyport/zero-downtime-api-key-rotation-why-you-shouldnt-call-rotate-first-3h7n</link>
      <guid>https://dev.to/unifyport/zero-downtime-api-key-rotation-why-you-shouldnt-call-rotate-first-3h7n</guid>
      <description>&lt;p&gt;API key rotation sounds like a single operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Old key → Rotate → New key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a production system, that sequence can cause an authentication outage.&lt;/p&gt;

&lt;p&gt;The problem is not generating the new credential. The problem is that your web servers, background workers, scheduled jobs, and operational scripts do not all start using it at the same moment.&lt;/p&gt;

&lt;p&gt;With the UnifyPort API, calling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/api-keys/{key_id}/rotate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;invalidates the old key immediately.&lt;/p&gt;

&lt;p&gt;That behavior is useful during a credential leak, but it is usually the wrong starting point for an ordinary rolling deployment.&lt;/p&gt;

&lt;p&gt;For zero-downtime rotation, use a controlled overlap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create → Store → Test → Deploy → Verify → Deactivate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The two rotation strategies
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Old key&lt;/th&gt;
&lt;th&gt;Best use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Call the rotate endpoint&lt;/td&gt;
&lt;td&gt;Invalidated immediately&lt;/td&gt;
&lt;td&gt;Suspected credential exposure or coordinated maintenance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create a second key, then deactivate the old one&lt;/td&gt;
&lt;td&gt;Remains active during deployment&lt;/td&gt;
&lt;td&gt;Normal rolling deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distinction matters because a production deployment rarely changes every process atomically.&lt;/p&gt;

&lt;p&gt;During a rolling release, some instances may already use the new configuration while others are still completing requests with the previous configuration. If the old key is revoked too early, otherwise healthy requests begin failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;401 invalid_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A safer mental model
&lt;/h2&gt;

&lt;p&gt;Treat credential rotation as a migration, not a single API call.&lt;/p&gt;

&lt;p&gt;The application moves through four states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State 1: Old key active
State 2: Old and new keys active
State 3: All callers use the new key
State 4: Old key inactive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State 2 is the overlap window that protects availability.&lt;/p&gt;

&lt;p&gt;Keep that window short, but do not eliminate it until you have evidence that every caller has migrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Inventory every API caller
&lt;/h2&gt;

&lt;p&gt;Before creating or revoking anything, identify every component that sends &lt;code&gt;X-Api-Key&lt;/code&gt; to the API.&lt;/p&gt;

&lt;p&gt;Typical callers include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;public web and API services;&lt;/li&gt;
&lt;li&gt;queue consumers;&lt;/li&gt;
&lt;li&gt;background workers;&lt;/li&gt;
&lt;li&gt;scheduled jobs;&lt;/li&gt;
&lt;li&gt;webhook handlers that send replies;&lt;/li&gt;
&lt;li&gt;health checks;&lt;/li&gt;
&lt;li&gt;production support scripts;&lt;/li&gt;
&lt;li&gt;regional deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not confuse an API key with a webhook &lt;code&gt;signing_secret&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They protect different directions of communication:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Api-Key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Authenticates requests your application sends to the REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;signing_secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifies webhook requests delivered to your endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rotating one does not rotate the other.&lt;/p&gt;

&lt;p&gt;You can inspect existing API key records with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.unifyport.ai/v1/api-keys &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes metadata such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key_prefix&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not return the complete secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a second active key
&lt;/h2&gt;

&lt;p&gt;For a normal deployment, create another key instead of rotating the existing one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.unifyport.ai/v1/api-keys &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Production 2026-08 cutover",
    "prefix": "dk_live"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful &lt;code&gt;201&lt;/code&gt; response returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;key metadata under &lt;code&gt;data.key&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the complete new secret under &lt;code&gt;data.api_key&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full value is returned only once.&lt;/p&gt;

&lt;p&gt;Capture it directly into your approved secret-management system. Do not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;print it in deployment logs;&lt;/li&gt;
&lt;li&gt;paste it into a ticket;&lt;/li&gt;
&lt;li&gt;send it through chat;&lt;/li&gt;
&lt;li&gt;commit it to the repository;&lt;/li&gt;
&lt;li&gt;assume it can be retrieved from the list endpoint later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the value is lost before deployment, create another key and deactivate the unused record.&lt;/p&gt;

&lt;p&gt;A key prefix is metadata, not enough information to recover the secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Test the new key before deployment
&lt;/h2&gt;

&lt;p&gt;Before changing application configuration, prove that the new credential can authenticate successfully.&lt;/p&gt;

&lt;p&gt;Use a read-only endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.unifyport.ai/v1/workspace &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$NEW_UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful response confirms that the key resolves to the expected workspace.&lt;/p&gt;

&lt;p&gt;This is only the first verification gate. It proves that the credential works, but it does not prove that every application instance has loaded it.&lt;/p&gt;

&lt;p&gt;Keep the old key active and deploy the new secret through your normal configuration system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Roll out and verify every caller
&lt;/h2&gt;

&lt;p&gt;Update each caller in controlled batches.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Web/API instances
2. Queue consumers
3. Background workers
4. Scheduled jobs
5. Operational scripts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each group, verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the deployment completed;&lt;/li&gt;
&lt;li&gt;processes restarted or reloaded their configuration;&lt;/li&gt;
&lt;li&gt;authenticated requests succeed;&lt;/li&gt;
&lt;li&gt;no instance still references the old secret;&lt;/li&gt;
&lt;li&gt;scheduled jobs will load the new value on their next run;&lt;/li&gt;
&lt;li&gt;emergency scripts have also been updated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not assume the migration is complete just because one HTTP request succeeded.&lt;/p&gt;

&lt;p&gt;Also avoid relying on metadata that the API does not provide. If the key-list response does not expose per-key last-used analytics, it cannot prove that the old key is unused.&lt;/p&gt;

&lt;p&gt;Use deployment state and application-side request results as your evidence.&lt;/p&gt;

&lt;p&gt;A simple internal checklist might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rollout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scheduledJobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;supportTools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safeToDeactivate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rollout&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact implementation is less important than making the verification state explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Deactivate the old key
&lt;/h2&gt;

&lt;p&gt;After every caller is confirmed on the new credential, deactivate the old record using the new key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.unifyport.ai/v1/api-keys/&lt;/span&gt;&lt;span class="nv"&gt;$OLD_KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$NEW_UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"inactive"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documented API key states are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;active
inactive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After deactivation, make one controlled read-only request with the old credential and confirm that it returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;401 invalid_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not use a real customer request or production job as the test.&lt;/p&gt;

&lt;p&gt;Finally, remove the retired secret from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deployment configuration;&lt;/li&gt;
&lt;li&gt;CI/CD variables;&lt;/li&gt;
&lt;li&gt;local environment files;&lt;/li&gt;
&lt;li&gt;temporary migration files;&lt;/li&gt;
&lt;li&gt;operational scripts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The change record should contain non-secret metadata only, such as the key ID, key name, owner, status, deployment version, and cutover time.&lt;/p&gt;

&lt;h2&gt;
  
  
  When immediate rotation is the right choice
&lt;/h2&gt;

&lt;p&gt;The dedicated rotate endpoint is appropriate when immediate invalidation is the requirement.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the old credential may have leaked;&lt;/li&gt;
&lt;li&gt;it appeared in a log or repository;&lt;/li&gt;
&lt;li&gt;an unauthorized person may have accessed it;&lt;/li&gt;
&lt;li&gt;all callers can switch together during a maintenance window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The incident-response sequence is different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Stop or isolate callers using the old key
2. Call the rotate endpoint
3. Capture the new secret once
4. Update every credential consumer
5. Restore traffic
6. Verify authentication
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this situation, revocation speed is more important than continuous availability.&lt;/p&gt;

&lt;p&gt;Do not keep a potentially compromised credential active merely to preserve an overlap window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off of overlapping keys
&lt;/h2&gt;

&lt;p&gt;A zero-downtime migration briefly leaves two valid credentials active.&lt;/p&gt;

&lt;p&gt;That creates a larger credential surface for a limited period, so the overlap should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;planned;&lt;/li&gt;
&lt;li&gt;monitored;&lt;/li&gt;
&lt;li&gt;access-controlled;&lt;/li&gt;
&lt;li&gt;as short as operationally practical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a deliberate availability-versus-exposure trade-off.&lt;/p&gt;

&lt;p&gt;For a normal rolling deployment, the short overlap prevents avoidable outages. For a security incident, immediate revocation usually takes priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation checklist
&lt;/h2&gt;

&lt;p&gt;Before deactivating the old key, confirm all of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every API caller has been identified.&lt;/li&gt;
&lt;li&gt;[ ] A second active key has been created.&lt;/li&gt;
&lt;li&gt;[ ] The one-time secret is stored securely.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;GET /v1/workspace&lt;/code&gt; succeeds with the new key.&lt;/li&gt;
&lt;li&gt;[ ] Web and API instances use the new key.&lt;/li&gt;
&lt;li&gt;[ ] Workers and queue consumers use the new key.&lt;/li&gt;
&lt;li&gt;[ ] Scheduled jobs will load the new key.&lt;/li&gt;
&lt;li&gt;[ ] Operational scripts have been updated.&lt;/li&gt;
&lt;li&gt;[ ] Authenticated production paths have been verified.&lt;/li&gt;
&lt;li&gt;[ ] The old key has been changed to &lt;code&gt;inactive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] A controlled request confirms the old key is rejected.&lt;/li&gt;
&lt;li&gt;[ ] The retired value has been removed from configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;An API named &lt;code&gt;rotate&lt;/code&gt; does not necessarily provide a zero-downtime migration.&lt;/p&gt;

&lt;p&gt;Always check the endpoint’s actual contract.&lt;/p&gt;

&lt;p&gt;If rotation invalidates the old credential immediately, use it for urgent revocation or a coordinated cutover.&lt;/p&gt;

&lt;p&gt;For an ordinary rolling deployment, the safer sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a second key
        ↓
Store it securely
        ↓
Test it
        ↓
Deploy it everywhere
        ↓
Verify every caller
        ↓
Deactivate the old key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key idea is simple: prove that the new credential is in use before removing the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/api-keys/create-api-key/" rel="noopener noreferrer"&gt;Create API key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/api-keys/list-api-keys/" rel="noopener noreferrer"&gt;List API keys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/api-keys/update-api-key/" rel="noopener noreferrer"&gt;Update API key status&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/api-keys/rotate-api-key/" rel="noopener noreferrer"&gt;Rotate API key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-53r5.pdf" rel="noopener noreferrer"&gt;NIST SP 800-53 Revision 5&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was adapted from an original UnifyPort technical guide with AI-assisted editing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>devops</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>WhatsApp Contact APIs: Add a Contact or Send a vCard?</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:21:17 +0000</pubDate>
      <link>https://dev.to/unifyport/whatsapp-contact-apis-add-a-contact-or-send-a-vcard-3gj2</link>
      <guid>https://dev.to/unifyport/whatsapp-contact-apis-add-a-contact-or-send-a-vcard-3gj2</guid>
      <description>&lt;h2&gt;
  
  
  Two Similar Requests, Two Different Operations
&lt;/h2&gt;

&lt;p&gt;“Add this person to WhatsApp contacts” and “send this person’s contact card” may sound similar, but they describe two different API operations.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a contact&lt;/strong&gt; changes the connected WhatsApp account’s address book.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send a vCard&lt;/strong&gt; delivers structured contact information inside a conversation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sending a vCard does not save that person to the connected account’s contacts. Adding a contact does not send anything to a user.&lt;/p&gt;

&lt;p&gt;Choosing the wrong operation can create confusing workflows, duplicate mutations, and incorrect retry behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Decision Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;API operation&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Save someone to the connected account’s contact list&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /v1/accounts/{account_id}/contacts/add&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns a contact object and identifiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share a person’s contact details in a chat&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /v1/messages&lt;/code&gt; with &lt;code&gt;message.type: "contact"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Returns an accepted message result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Save a customer and send a representative’s card&lt;/td&gt;
&lt;td&gt;Call both operations separately&lt;/td&gt;
&lt;td&gt;Two independent results and retry paths&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key distinction is state versus content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add contact
    -&amp;gt; changes account state

Send vCard
    -&amp;gt; sends conversation content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Option 1: Add a Contact to the Connected Account
&lt;/h2&gt;

&lt;p&gt;Use the add-contact operation when your application needs to save someone to the connected WhatsApp account’s contact list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.unifyport.ai/v1/accounts/acc_example/contacts/add"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "phone_number": "15550001111",
    "whatsapp_options": {
      "first_name": "Jane",
      "full_name": "Jane Doe",
      "sync_to_device_contacts": false
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request must contain at least one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;phone_number&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;username&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WhatsApp-specific fields belong inside &lt;code&gt;whatsapp_options&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;first_name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;full_name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sync_to_device_contacts&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not move these fields to the top level of the request.&lt;/p&gt;

&lt;p&gt;A successful response returns the contact representation. Preserve identifiers such as &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;conversation_id&lt;/code&gt; from the response.&lt;/p&gt;

&lt;p&gt;Do not construct a conversation identifier from the phone number. The provider’s canonical identifier may use a different format, so the API response should remain the source of truth.&lt;/p&gt;

&lt;p&gt;See the complete &lt;a href="https://www.unifyport.ai/docs/contacts/add-contact/" rel="noopener noreferrer"&gt;Add contact API reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Send a vCard in a Conversation
&lt;/h2&gt;

&lt;p&gt;Use the unified message endpoint when the requirement is to share contact details with a user or group.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.unifyport.ai/v1/messages"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$UNIFYPORT_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "account_id": "acc_example",
    "to": {
      "id": "15550002222@s.whatsapp.net",
      "type": "user"
    },
    "message": {
      "type": "contact",
      "contacts": [
        {
          "name": "Jane Doe",
          "phones": [
            {
              "number": "+15550001111",
              "type": "CELL"
            }
          ],
          "emails": [
            {
              "address": "jane@example.com"
            }
          ],
          "organization": "Example Company",
          "title": "Product Manager"
        }
      ]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every card in &lt;code&gt;message.contacts&lt;/code&gt; requires a non-empty &lt;code&gt;name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The following fields are optional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;phones[].number&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;phones[].type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;emails[].address&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;organization&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;title&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can send one or multiple contact cards in the same request.&lt;/p&gt;

&lt;p&gt;The API generates the vCard from structured JSON, so you do not need to construct raw &lt;code&gt;BEGIN:VCARD&lt;/code&gt; content manually.&lt;/p&gt;

&lt;p&gt;See the complete &lt;a href="https://www.unifyport.ai/docs/messages/send-contact-message/" rel="noopener noreferrer"&gt;Send contact message API reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why These Calls Should Remain Separate
&lt;/h2&gt;

&lt;p&gt;Consider a customer-support handoff:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save the customer to the connected WhatsApp account.&lt;/li&gt;
&lt;li&gt;Send the customer a contact card for the assigned account manager.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Although both steps involve contacts, they have different side effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 1: Add customer
Result: Address-book mutation

Step 2: Send account manager's card
Result: Outbound message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the two results independently.&lt;/p&gt;

&lt;p&gt;A successful address-book update does not prove that the contact message was accepted. An accepted contact message does not prove that the address book changed.&lt;/p&gt;

&lt;p&gt;This separation becomes especially important during retries.&lt;/p&gt;

&lt;p&gt;If sending the vCard fails after the contact was successfully added, retry only the message operation. Repeating the already successful address-book mutation adds unnecessary work and may produce provider-specific conflicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the Workflow Explicitly
&lt;/h2&gt;

&lt;p&gt;A simple TypeScript workflow could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ContactWorkflowResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;addedContactId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sentMessageId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addCustomerAndSendRepresentativeCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;customerPhone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ContactWorkflowResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ContactWorkflowResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addContact&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;phoneNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerPhone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;whatsappOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Customer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;syncToDeviceContacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addedContactId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendContactMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Jane Doe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;phones&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+15550001111&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CELL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="na"&gt;organization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Example Company&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account Manager&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sentMessageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a production system, persist the successful result of each step before starting the next one. That allows a failed workflow to resume from the correct operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle Errors by Operation
&lt;/h2&gt;

&lt;p&gt;Do not place every contact-related error into one generic handler.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add contact&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400 invalid_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Neither &lt;code&gt;phone_number&lt;/code&gt; nor &lt;code&gt;username&lt;/code&gt; is valid or present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add contact&lt;/td&gt;
&lt;td&gt;&lt;code&gt;501 unsupported_by_provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The provider does not implement address-book contact creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Send vCard&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400 invalid_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The contacts array is empty or a card is missing &lt;code&gt;name&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Send vCard&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400 unsupported_message_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The selected account does not support structured contact messages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A practical error-handling structure is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executeContactOperation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// Correct the request instead of retrying it unchanged.&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsupported_by_provider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsupported_message_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// Use a documented fallback or disable the feature.&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// Apply bounded retry rules only to transient failures.&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation and capability errors should not be retried with the same request.&lt;/p&gt;

&lt;p&gt;For transient failures, use bounded retries and an idempotency strategy appropriate to the operation. Never treat a message retry as permission to repeat a completed address-book mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design a Cross-Provider Fallback
&lt;/h2&gt;

&lt;p&gt;Structured contact messages are currently WhatsApp-specific in this integration.&lt;/p&gt;

&lt;p&gt;Before exposing a universal “send contact” button across WhatsApp, Telegram, LINE, TikTok, Zalo, and X, check the &lt;a href="https://www.unifyport.ai/docs/provider-guide/provider-message-support/" rel="noopener noreferrer"&gt;provider message support matrix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When structured cards are unavailable, a text fallback can preserve the essential information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Jane Doe
Product Manager, Example Company
Phone: +1 555-000-1111
Email: jane@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make the fallback explicit in your product design. Silently converting structured content can create inconsistent formatting and privacy behavior across channels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Personal Data Minimal
&lt;/h2&gt;

&lt;p&gt;Contact cards can contain personal information. Only include fields needed for the actual workflow.&lt;/p&gt;

&lt;p&gt;Avoid sending phone numbers, email addresses, job titles, or organization details simply because the schema allows them.&lt;/p&gt;

&lt;p&gt;Also remember that sending a vCard does not force the recipient to save it. The recipient’s WhatsApp client and their own actions determine how the card is displayed and whether it becomes a saved contact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping, verify that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The product requirement distinguishes address-book changes from chat messages.&lt;/li&gt;
&lt;li&gt;Add-contact requests contain &lt;code&gt;phone_number&lt;/code&gt; or &lt;code&gt;username&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;WhatsApp-specific fields are placed under &lt;code&gt;whatsapp_options&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Returned contact and conversation identifiers are persisted.&lt;/li&gt;
&lt;li&gt;Contact messages contain a non-empty &lt;code&gt;contacts&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;Every card contains &lt;code&gt;name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add and send results are recorded independently.&lt;/li&gt;
&lt;li&gt;Retries resume from the failed step.&lt;/li&gt;
&lt;li&gt;Capability errors do not enter an automatic retry loop.&lt;/li&gt;
&lt;li&gt;Unsupported providers have an explicit fallback.&lt;/li&gt;
&lt;li&gt;Only necessary personal data is included.&lt;/li&gt;
&lt;li&gt;Tests confirm that sending a vCard does not trigger contact creation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Takeaway
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;/contacts/add&lt;/code&gt; when you need to change the connected WhatsApp account’s contact list.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;/v1/messages&lt;/code&gt; with &lt;code&gt;message.type: "contact"&lt;/code&gt; when you need to deliver contact details inside a chat.&lt;/p&gt;

&lt;p&gt;Some workflows need both, but they should remain two explicit operations with separate results, errors, and retry policies.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.unifyport.ai/blog/whatsapp-contact-api-add-contact-vs-send-vcard/" rel="noopener noreferrer"&gt;UnifyPort&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: AI-assisted drafting and editing were used to prepare this DEV adaptation. The technical content was reviewed against the linked API documentation.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>api</category>
      <category>backend</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>TikTok Shop Customer Service Webhooks: A Production-Ready Implementation Guide</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Tue, 11 Aug 2026 03:22:12 +0000</pubDate>
      <link>https://dev.to/unifyport/tiktok-shop-customer-service-webhooks-a-production-ready-implementation-guide-2cid</link>
      <guid>https://dev.to/unifyport/tiktok-shop-customer-service-webhooks-a-production-ready-implementation-guide-2cid</guid>
      <description>&lt;p&gt;Receiving a webhook is easy.&lt;/p&gt;

&lt;p&gt;Building a webhook pipeline that survives duplicate deliveries, delayed events, missing messages, invalid signatures, and seller authorization changes is the real engineering work.&lt;/p&gt;

&lt;p&gt;This guide explains how to build a production-ready pipeline for TikTok Shop Customer Service messages—from HTTPS ingress to history reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, Understand the Scope
&lt;/h2&gt;

&lt;p&gt;TikTok Shop Customer Service API is designed for conversations between buyers and sellers in a TikTok Shop.&lt;/p&gt;

&lt;p&gt;It is not an API for reading ordinary TikTok direct messages.&lt;/p&gt;

&lt;p&gt;Before implementation, confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your application has access to the required Customer Service API scopes.&lt;/li&gt;
&lt;li&gt;The seller has authorized your application.&lt;/li&gt;
&lt;li&gt;The target shop is correctly mapped to your internal workspace or tenant.&lt;/li&gt;
&lt;li&gt;Your webhook endpoint is publicly accessible over HTTPS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Customer Service API access is inactive by default and requires approval. See the official &lt;a href="https://partner.tiktokshop.com/docv2/page/customer-service-api-overview" rel="noopener noreferrer"&gt;Customer Service API overview&lt;/a&gt; and &lt;a href="https://partner.tiktokshop.com/docv2/page/app-features" rel="noopener noreferrer"&gt;app features documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If these prerequisites are missing, changing webhook code will not solve the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommended Architecture
&lt;/h2&gt;

&lt;p&gt;A reliable implementation separates webhook acknowledgement from business processing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TikTok Shop
    |
    v
HTTPS webhook ingress
    |
    +-- Verify signature using raw request body
    |
    +-- Insert event into a durable inbox
    |
    +-- Return HTTP 200 within 3 seconds
    |
    v
Message queue
    |
    v
Normalize, deduplicate, and route
    |
    v
Customer service workspace
    ^
    |
History reconciliation worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webhook request should not wait for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CRM updates&lt;/li&gt;
&lt;li&gt;AI-generated replies&lt;/li&gt;
&lt;li&gt;Media downloads&lt;/li&gt;
&lt;li&gt;Ticket creation&lt;/li&gt;
&lt;li&gt;Search indexing&lt;/li&gt;
&lt;li&gt;External notifications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Persist the event, acknowledge it, and process it asynchronously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscribe to the New Message Event
&lt;/h2&gt;

&lt;p&gt;For incoming customer service messages, subscribe to &lt;code&gt;NEW_MESSAGE&lt;/code&gt;, identified as event type &lt;code&gt;14&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can configure the subscription in TikTok Shop Partner Center or through the webhook configuration API.&lt;/p&gt;

&lt;p&gt;The official event reference is available in the &lt;a href="https://partner.tiktokshop.com/docv2/page/14-new-message" rel="noopener noreferrer"&gt;New Message webhook documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Useful fields from the payload include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tts_notification_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;shop_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;message_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversation_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;index&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;create_time&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;visibility&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Sender information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Preserve these identifiers before converting the payload into your internal message model. They are essential for deduplication, routing, ordering, and incident investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the Signature Before Parsing
&lt;/h2&gt;

&lt;p&gt;TikTok Shop sends the webhook signature in the &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;According to the official &lt;a href="https://partner.tiktokshop.com/docv2/page/tts-webhooks-overview" rel="noopener noreferrer"&gt;webhook overview&lt;/a&gt;, the signature is generated with HMAC-SHA256 using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signature content: &lt;code&gt;app_key&lt;/code&gt; followed by the raw webhook body&lt;/li&gt;
&lt;li&gt;Signing key: the application's &lt;code&gt;app_secret&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important word here is &lt;strong&gt;raw&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If your framework parses and serializes the JSON before verification, whitespace or character encoding changes can invalidate an otherwise legitimate signature.&lt;/p&gt;

&lt;p&gt;A Node.js implementation can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyTikTokShopWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;appKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;appSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;appSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-f0-9&lt;/span&gt;&lt;span class="se"&gt;]{64}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actualBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;actualBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actualBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedBuffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use constant-time comparison and never log &lt;code&gt;app_secret&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you use Express, configure raw-body handling specifically for the webhook route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/tiktok-shop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifyTikTokShopWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIKTOK_SHOP_APP_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIKTOK_SHOP_APP_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webhookInbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;notificationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tts_notification_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;shopId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shop_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure another global JSON middleware does not consume this route first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledge Within Three Seconds
&lt;/h2&gt;

&lt;p&gt;TikTok Shop requires a successful webhook response to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use HTTP status &lt;code&gt;200&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Contain an empty response body&lt;/li&gt;
&lt;li&gt;Arrive within three seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoint must support HTTPS with TLS 1.2 or newer. TikTok Shop also documents retry behavior when delivery fails or times out. See the official &lt;a href="https://partner.tiktokshop.com/docv2/page/configuration-guide" rel="noopener noreferrer"&gt;webhook configuration guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That creates a strict boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook request = authenticate + persist + acknowledge
Worker job      = process + normalize + route + enrich
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not acknowledge an event before it reaches durable storage. Otherwise, a process crash between acknowledgement and persistence can permanently lose the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make Every Delivery Idempotent
&lt;/h2&gt;

&lt;p&gt;Webhook delivery is at-least-once behavior in practice. Your system must expect the same event to arrive more than once.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;tts_notification_id&lt;/code&gt; as the first idempotency key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;webhook_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;notification_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the normalized message layer, also enforce uniqueness on &lt;code&gt;message_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These keys protect different stages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tts_notification_id&lt;/code&gt; prevents duplicate webhook processing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;message_id&lt;/code&gt; prevents duplicate customer messages.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;conversation_id&lt;/code&gt; identifies the message stream.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shop_id&lt;/code&gt; determines tenant or workspace routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A duplicate webhook should still receive &lt;code&gt;200&lt;/code&gt;. Duplication is an expected delivery condition, not an application error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do Not Trust Arrival Order
&lt;/h2&gt;

&lt;p&gt;Network delivery order is not guaranteed.&lt;/p&gt;

&lt;p&gt;A newer message may arrive before an older one, and a retry may arrive after both. Use the message &lt;code&gt;index&lt;/code&gt; and &lt;code&gt;conversation_id&lt;/code&gt; to track ordering instead of assuming that request arrival time reflects conversation order.&lt;/p&gt;

&lt;p&gt;A simplified worker check might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastIndex&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;scheduleHistoryReconciliation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preserve &lt;code&gt;create_time&lt;/code&gt;, but do not use it as your only ordering mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile Missing Messages Through History
&lt;/h2&gt;

&lt;p&gt;Webhooks should not be treated as the complete source of truth. TikTok Shop explicitly recommends using API queries when applications need to recover or validate state.&lt;/p&gt;

&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /customer_service/202309/conversations/{conversation_id}/messages
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://partner.tiktokshop.com/docv2/page/get-conversation-messages-202309" rel="noopener noreferrer"&gt;Get Conversation Messages documentation&lt;/a&gt; specifies that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The required scope is &lt;code&gt;seller.customer_service&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;page_size&lt;/code&gt; has a maximum value of 10.&lt;/li&gt;
&lt;li&gt;Pagination uses &lt;code&gt;next_page_token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fetching messages does not mark them as read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trigger reconciliation when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A message index gap is detected.&lt;/li&gt;
&lt;li&gt;A worker repeatedly fails to process an event.&lt;/li&gt;
&lt;li&gt;A shop reconnects after authorization problems.&lt;/li&gt;
&lt;li&gt;Monitoring detects an inactive conversation stream.&lt;/li&gt;
&lt;li&gt;Support reports a missing message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Insert recovered messages through the same idempotent normalization pipeline used for webhook events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Routing Separate from Authentication
&lt;/h2&gt;

&lt;p&gt;A valid signature proves that the request came from TikTok Shop. It does not tell you which internal customer owns the shop.&lt;/p&gt;

&lt;p&gt;Maintain an explicit mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shop_id
  -&amp;gt; seller authorization
  -&amp;gt; internal tenant
  -&amp;gt; customer service workspace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;shop_id&lt;/code&gt; is unknown, store the event in an unroutable state instead of discarding it.&lt;/p&gt;

&lt;p&gt;This makes delayed provisioning, authorization changes, and mapping mistakes recoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Track Authorization as Its Own Lifecycle
&lt;/h2&gt;

&lt;p&gt;Seller authorization can expire or be revoked independently of webhook delivery.&lt;/p&gt;

&lt;p&gt;Treat these as different failure domains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook authentication&lt;/li&gt;
&lt;li&gt;Seller access-token validity&lt;/li&gt;
&lt;li&gt;API scope approval&lt;/li&gt;
&lt;li&gt;Shop-to-tenant routing&lt;/li&gt;
&lt;li&gt;Downstream message processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, a webhook may be valid while the history API request fails because the seller token is no longer usable.&lt;/p&gt;

&lt;p&gt;Do not classify every API failure as a webhook or network failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor the Pipeline, Not Just the Endpoint
&lt;/h2&gt;

&lt;p&gt;A webhook endpoint returning &lt;code&gt;200&lt;/code&gt; does not prove that messages are reaching the customer service workspace.&lt;/p&gt;

&lt;p&gt;Useful production metrics include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook acknowledgement latency at p95 and p99&lt;/li&gt;
&lt;li&gt;Invalid signature count&lt;/li&gt;
&lt;li&gt;Duplicate notification count&lt;/li&gt;
&lt;li&gt;Durable inbox insertion failures&lt;/li&gt;
&lt;li&gt;Queue age&lt;/li&gt;
&lt;li&gt;Processing retry count&lt;/li&gt;
&lt;li&gt;Conversation index gaps&lt;/li&gt;
&lt;li&gt;History reconciliation count&lt;/li&gt;
&lt;li&gt;Unroutable &lt;code&gt;shop_id&lt;/code&gt; count&lt;/li&gt;
&lt;li&gt;End-to-end message delivery latency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Logs should include safe correlation fields such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tts_notification_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;shop_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversation_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;message_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Worker job ID&lt;/li&gt;
&lt;li&gt;Internal tenant ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not log access tokens, application secrets, or full authorization headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Failure Modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;What to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Webhook returns &lt;code&gt;401&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Signature mismatch&lt;/td&gt;
&lt;td&gt;Raw body handling, app key, app secret, header parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook returns &lt;code&gt;200&lt;/code&gt;, but no ticket appears&lt;/td&gt;
&lt;td&gt;Async processing failure&lt;/td&gt;
&lt;td&gt;Inbox row, queue job, worker logs, tenant routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same message appears twice&lt;/td&gt;
&lt;td&gt;Missing idempotency&lt;/td&gt;
&lt;td&gt;Unique constraints for notification and message IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation has missing messages&lt;/td&gt;
&lt;td&gt;Delivery gap or processing failure&lt;/td&gt;
&lt;td&gt;Message index and history reconciliation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message reaches the wrong workspace&lt;/td&gt;
&lt;td&gt;Incorrect shop mapping&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;shop_id&lt;/code&gt; ownership and authorization mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History API fails&lt;/td&gt;
&lt;td&gt;Scope or token problem&lt;/td&gt;
&lt;td&gt;Seller authorization and &lt;code&gt;seller.customer_service&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fetching history does not update unread status&lt;/td&gt;
&lt;td&gt;Expected behavior&lt;/td&gt;
&lt;td&gt;Handle read state through the appropriate API separately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Production Acceptance Test
&lt;/h2&gt;

&lt;p&gt;Before launch, perform a real end-to-end test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authorize a real test shop.&lt;/li&gt;
&lt;li&gt;Send a buyer message through TikTok Shop.&lt;/li&gt;
&lt;li&gt;Confirm that the webhook signature is accepted.&lt;/li&gt;
&lt;li&gt;Confirm that the raw event reaches durable storage.&lt;/li&gt;
&lt;li&gt;Confirm that the endpoint responds with an empty &lt;code&gt;200&lt;/code&gt; within three seconds.&lt;/li&gt;
&lt;li&gt;Confirm that the worker creates exactly one normalized message.&lt;/li&gt;
&lt;li&gt;Replay the webhook and verify that no duplicate message is created.&lt;/li&gt;
&lt;li&gt;Simulate an index gap and confirm that history reconciliation runs.&lt;/li&gt;
&lt;li&gt;Confirm that the message is routed to the correct workspace.&lt;/li&gt;
&lt;li&gt;Inspect the webhook delivery record in Partner Center.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A manually constructed HTTP request can test your endpoint mechanics, but it cannot prove that seller authorization, event subscription, TikTok delivery, and production routing all work together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Checklist
&lt;/h2&gt;

&lt;p&gt;Before going live, verify that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Customer Service API access is approved.&lt;/li&gt;
&lt;li&gt;The seller has authorized the application.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEW_MESSAGE&lt;/code&gt; is subscribed.&lt;/li&gt;
&lt;li&gt;The endpoint uses HTTPS and TLS 1.2 or newer.&lt;/li&gt;
&lt;li&gt;Signatures are verified against the raw request body.&lt;/li&gt;
&lt;li&gt;Events are durably stored before acknowledgement.&lt;/li&gt;
&lt;li&gt;Empty &lt;code&gt;200&lt;/code&gt; responses are returned within three seconds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tts_notification_id&lt;/code&gt; and &lt;code&gt;message_id&lt;/code&gt; are deduplicated.&lt;/li&gt;
&lt;li&gt;Message ordering uses conversation identifiers and indexes.&lt;/li&gt;
&lt;li&gt;History reconciliation handles missing events.&lt;/li&gt;
&lt;li&gt;Shop routing and authorization lifecycles are observable.&lt;/li&gt;
&lt;li&gt;A real Shop-to-workspace acceptance test has passed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Production webhook engineering is less about receiving JSON and more about maintaining consistency across two systems that communicate asynchronously.&lt;/p&gt;

&lt;p&gt;Once acknowledgement, idempotency, ordering, reconciliation, and authorization are treated as first-class components, the integration becomes much easier to operate.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.unifyport.ai/blog/tiktok-shop-customer-service-api-webhook-production-checklist/" rel="noopener noreferrer"&gt;UnifyPort&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: AI-assisted drafting and editing were used in preparing this article. The technical content was reviewed against the referenced documentation.&lt;/p&gt;

</description>
      <category>tiktok</category>
      <category>webhooks</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>LINE Service Messages vs Messaging API: Choose the Right Message Path</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Fri, 07 Aug 2026 05:43:02 +0000</pubDate>
      <link>https://dev.to/unifyport/line-service-messages-vs-messaging-api-choose-the-right-message-path-2209</link>
      <guid>https://dev.to/unifyport/line-service-messages-vs-messaging-api-choose-the-right-message-path-2209</guid>
      <description>&lt;p&gt;LINE MINI App Service Messages and the LINE Messaging API can both deliver messages to users, but they solve different problems.&lt;/p&gt;

&lt;p&gt;A Service Message confirms or follows up on an action completed inside a LINE MINI App. A Messaging API message comes from a LINE Official Account and supports conversations, direct outreach, and audience messaging.&lt;/p&gt;

&lt;p&gt;They differ in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sender identity&lt;/li&gt;
&lt;li&gt;Trigger&lt;/li&gt;
&lt;li&gt;Recipient model&lt;/li&gt;
&lt;li&gt;Token lifecycle&lt;/li&gt;
&lt;li&gt;Message format&lt;/li&gt;
&lt;li&gt;Review requirements&lt;/li&gt;
&lt;li&gt;Chat room&lt;/li&gt;
&lt;li&gt;Pricing&lt;/li&gt;
&lt;li&gt;Content policy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choosing the wrong path can result in rejected templates, unusable tokens, duplicate notifications, or an architecture that cannot support customer conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;Use a LINE MINI App Service Message when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The user completed an action inside the MINI App&lt;/li&gt;
&lt;li&gt;The message confirms, reports, or reminds the user about that action&lt;/li&gt;
&lt;li&gt;The MINI App is verified&lt;/li&gt;
&lt;li&gt;The template has passed review&lt;/li&gt;
&lt;li&gt;You have a valid service notification token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the Messaging API when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sender should be a LINE Official Account&lt;/li&gt;
&lt;li&gt;You need to reply to a user conversation&lt;/li&gt;
&lt;li&gt;You need push, multicast, narrowcast, or broadcast delivery&lt;/li&gt;
&lt;li&gt;You need flexible message objects&lt;/li&gt;
&lt;li&gt;You are operating under the Official Account recipient, quota, and pricing model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One API is not an upgraded version of the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the two paths
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision point&lt;/th&gt;
&lt;th&gt;Service Message API&lt;/th&gt;
&lt;th&gt;Messaging API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;LINE MINI App&lt;/td&gt;
&lt;td&gt;LINE Official Account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purpose&lt;/td&gt;
&lt;td&gt;Transactional action confirmation, result, or reminder&lt;/td&gt;
&lt;td&gt;Conversation, support, outreach, and audience delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;User action inside the MINI App&lt;/td&gt;
&lt;td&gt;User webhook event or application decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recipient&lt;/td&gt;
&lt;td&gt;User associated with a service notification token&lt;/td&gt;
&lt;td&gt;User, group, chat, audience, or Official Account friends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production requirement&lt;/td&gt;
&lt;td&gt;Verified MINI App and reviewed template&lt;/td&gt;
&lt;td&gt;Messaging API channel connected to an Official Account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message design&lt;/td&gt;
&lt;td&gt;Reviewed LINE-provided template&lt;/td&gt;
&lt;td&gt;Text, image, video, Flex, template, sticker, location, and other objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat room&lt;/td&gt;
&lt;td&gt;Regional MINI App notice chat&lt;/td&gt;
&lt;td&gt;Chat with the Official Account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;Prohibited&lt;/td&gt;
&lt;td&gt;Supported through eligible methods and applicable rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message limit&lt;/td&gt;
&lt;td&gt;Normally up to five per qualifying action&lt;/td&gt;
&lt;td&gt;Monthly allowance, endpoint limits, and account plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;Described by LINE as free&lt;/td&gt;
&lt;td&gt;Depends on market and Official Account plan&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The official boundaries are documented in LINE's &lt;a href="https://developers.line.biz/en/docs/line-mini-app/develop/service-messages/" rel="noopener noreferrer"&gt;Service Message guide&lt;/a&gt; and &lt;a href="https://developers.line.biz/en/docs/messaging-api/sending-messages/" rel="noopener noreferrer"&gt;Messaging API sending guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service Messages begin with a MINI App action
&lt;/h2&gt;

&lt;p&gt;A Service Message should not exist without a specific action performed by the user inside the LINE MINI App.&lt;/p&gt;

&lt;p&gt;Appropriate examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reservation confirmation&lt;/li&gt;
&lt;li&gt;Order confirmation&lt;/li&gt;
&lt;li&gt;Check-in result&lt;/li&gt;
&lt;li&gt;Shipment completion&lt;/li&gt;
&lt;li&gt;Reservation reminder&lt;/li&gt;
&lt;li&gt;Reminder for a purchased ticket&lt;/li&gt;
&lt;li&gt;Queue-status update tied to a submitted request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relationship should be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User action
    ↓
Business transaction
    ↓
Service notification token
    ↓
Approved template
    ↓
Confirmation, result, or reminder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The following are not valid Service Message use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;General promotions&lt;/li&gt;
&lt;li&gt;Discount campaigns&lt;/li&gt;
&lt;li&gt;Coupons&lt;/li&gt;
&lt;li&gt;Shopping rewards&lt;/li&gt;
&lt;li&gt;New-product announcements&lt;/li&gt;
&lt;li&gt;Unrelated event notifications&lt;/li&gt;
&lt;li&gt;Messages triggered by actions outside the MINI App&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verification does not remove these restrictions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Messaging API messages begin with an Official Account
&lt;/h2&gt;

&lt;p&gt;The Messaging API provides these primary delivery methods:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reply&lt;/td&gt;
&lt;td&gt;Respond to a webhook event from a user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push&lt;/td&gt;
&lt;td&gt;Send to an eligible user, group, or multi-person chat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multicast&lt;/td&gt;
&lt;td&gt;Send to a specified list of user IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narrowcast&lt;/td&gt;
&lt;td&gt;Send to an audience or demographic segment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broadcast&lt;/td&gt;
&lt;td&gt;Send to all friends of the Official Account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It also supports a broader set of message objects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text&lt;/li&gt;
&lt;li&gt;Text v2&lt;/li&gt;
&lt;li&gt;Image&lt;/li&gt;
&lt;li&gt;Video&lt;/li&gt;
&lt;li&gt;Audio&lt;/li&gt;
&lt;li&gt;Sticker&lt;/li&gt;
&lt;li&gt;Location&lt;/li&gt;
&lt;li&gt;Imagemap&lt;/li&gt;
&lt;li&gt;Template&lt;/li&gt;
&lt;li&gt;Flex Message&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the Messaging API appropriate for customer support, chatbots, campaigns, and Official Account communication.&lt;/p&gt;

&lt;p&gt;However, it follows the recipient, friendship, quota, rate-limit, and pricing rules of the Official Account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand the token boundary
&lt;/h2&gt;

&lt;p&gt;The most common implementation error is treating all LINE tokens as interchangeable.&lt;/p&gt;

&lt;p&gt;They are not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credential or identifier&lt;/th&gt;
&lt;th&gt;Belongs to&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LIFF access token&lt;/td&gt;
&lt;td&gt;Current MINI App user session&lt;/td&gt;
&lt;td&gt;Helps issue the first service notification token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MINI App channel access token&lt;/td&gt;
&lt;td&gt;MINI App channel&lt;/td&gt;
&lt;td&gt;Authenticates Service Message API calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service notification token&lt;/td&gt;
&lt;td&gt;One MINI App user and action flow&lt;/td&gt;
&lt;td&gt;Sends reviewed Service Messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging API channel access token&lt;/td&gt;
&lt;td&gt;Official Account channel&lt;/td&gt;
&lt;td&gt;Authenticates Messaging API requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply token&lt;/td&gt;
&lt;td&gt;One eligible webhook event&lt;/td&gt;
&lt;td&gt;Sends a reply message once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LINE user ID&lt;/td&gt;
&lt;td&gt;User within a channel context&lt;/td&gt;
&lt;td&gt;Targets eligible Messaging API methods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audience ID&lt;/td&gt;
&lt;td&gt;Official Account audience&lt;/td&gt;
&lt;td&gt;Targets narrowcast operations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A Service notification token is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A permanent user ID&lt;/li&gt;
&lt;li&gt;A Messaging API reply token&lt;/li&gt;
&lt;li&gt;An Official Account push target&lt;/li&gt;
&lt;li&gt;A reusable cross-user credential&lt;/li&gt;
&lt;li&gt;A general-purpose chat identity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep each token type in a separate model and storage path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Service Message token flow
&lt;/h2&gt;

&lt;p&gt;The first Service Message normally starts with a LIFF access token obtained during the user action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MINI App
    ↓ liff.getAccessToken()
Backend
    ↓ POST /message/v3/notifier/token
Service notification token
    ↓ POST /message/v3/notifier/send?target=service
Service Message
    ↓
Renewed service notification token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LINE recommends stateless channel access tokens for LINE MINI App channels. Long-lived and v2.1 channel access tokens cannot be used for MINI App channels.&lt;/p&gt;

&lt;p&gt;The Service Message API returns state that must be preserved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ServiceNotificationState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;businessActionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;userReference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;notificationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;remainingCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important characteristics include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The token is associated with one user&lt;/li&gt;
&lt;li&gt;It normally permits up to five messages for the approved action&lt;/li&gt;
&lt;li&gt;It expires one year after issuance&lt;/li&gt;
&lt;li&gt;The token value is renewed after a successful send&lt;/li&gt;
&lt;li&gt;The renewed token must be used for the next message&lt;/li&gt;
&lt;li&gt;One LIFF access token can issue only one Service notification token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not store the token in browser analytics or application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist the renewed token atomically
&lt;/h2&gt;

&lt;p&gt;Two workers must not send with the same Service notification token at the same time.&lt;/p&gt;

&lt;p&gt;A safe operation looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendActionUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reminder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;templateName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lockServiceNotificationState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;templateName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remainingCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service_message_count_exhausted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lineMiniApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendServiceMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;notificationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notificationToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;templateName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;templateName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateServiceNotificationState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;notificationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notificationToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;remainingCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remainingCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDelivery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;line_service_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact transaction implementation depends on your database and queue, but the invariants are the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock the action state&lt;/li&gt;
&lt;li&gt;Check idempotency&lt;/li&gt;
&lt;li&gt;Send once&lt;/li&gt;
&lt;li&gt;Save the renewed token&lt;/li&gt;
&lt;li&gt;Save the new remaining count&lt;/li&gt;
&lt;li&gt;Commit together&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the request result is uncertain, reconcile it before retrying blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reply tokens have a different lifecycle
&lt;/h2&gt;

&lt;p&gt;Messaging API reply tokens arrive in eligible webhook events.&lt;/p&gt;

&lt;p&gt;They are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tied to one event&lt;/li&gt;
&lt;li&gt;Usable only once&lt;/li&gt;
&lt;li&gt;Intended to be used immediately&lt;/li&gt;
&lt;li&gt;Not interchangeable with Service notification tokens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reply flow looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User sends message
       ↓
Official Account webhook
       ↓
Reply token
       ↓
POST /v2/bot/message/reply
       ↓
Reply in Official Account chat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response is generated too late or the reply token has already been consumed, your application must evaluate whether an eligible push message is appropriate.&lt;/p&gt;

&lt;p&gt;Do not store a reply token as a permanent conversation address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat templates differently
&lt;/h2&gt;

&lt;p&gt;Service Messages use LINE-provided templates that must pass review.&lt;/p&gt;

&lt;p&gt;Your application supplies approved variables and permanent links:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"templateName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reservation_confirmation_en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reservation_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"R-20260807-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reservation_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-08 19:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"button_uri_1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reservation/detail?id=R-20260807-001"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template must remain connected to the reviewed action.&lt;/p&gt;

&lt;p&gt;Messaging API message objects are more flexible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USER_ID_PLACEHOLDER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How can we help with your reservation?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This flexibility does not mean every recipient is eligible for every send method. Recipient and friendship rules still apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not send the same message twice by default
&lt;/h2&gt;

&lt;p&gt;A product that uses both APIs can accidentally send duplicate updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reservation completed
       ├── Service Message
       └── Messaging API push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unless duplication has a deliberate product reason, select one delivery path per purpose.&lt;/p&gt;

&lt;p&gt;A routing policy can be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MessagePurpose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction_confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction_result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction_reminder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support_reply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support_follow_up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marketing_campaign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectLineMessagePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessagePurpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messaging_api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction_confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction_result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction_reminder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support_reply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support_follow_up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marketing_campaign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messaging_api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final implementation must still check verification, template, recipient, consent, and quota requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Join both paths with your own business ID
&lt;/h2&gt;

&lt;p&gt;A Service notification token is not a customer-support identity.&lt;/p&gt;

&lt;p&gt;If a customer receives a reservation confirmation and later starts a support conversation, correlate both paths with your own transaction model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CustomerInteraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;businessActionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;reservationId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;lineMiniAppUserReference&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;officialAccountUserId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;supportConversationId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not assume identifiers from different LINE channels or products are globally interchangeable.&lt;/p&gt;

&lt;p&gt;Your application should own the relationship between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Customer&lt;/li&gt;
&lt;li&gt;Order or reservation&lt;/li&gt;
&lt;li&gt;MINI App action&lt;/li&gt;
&lt;li&gt;Service Message delivery&lt;/li&gt;
&lt;li&gt;Official Account conversation&lt;/li&gt;
&lt;li&gt;Support ticket&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recommended hybrid architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LINE MINI App action
        ↓
Transaction service
        ↓
Service Message worker
        ↓
Regional MINI App notice chat

Customer support message
        ↓
Official Account webhook
        ↓
Conversation service
        ↓
Messaging API reply

Both paths
        ↓
Shared customer/order database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credentials&lt;/li&gt;
&lt;li&gt;Token storage&lt;/li&gt;
&lt;li&gt;Queues&lt;/li&gt;
&lt;li&gt;Delivery logs&lt;/li&gt;
&lt;li&gt;Retry policies&lt;/li&gt;
&lt;li&gt;Rate and quota monitoring&lt;/li&gt;
&lt;li&gt;Compliance rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Share only stable business context such as an internal order or reservation ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failure modes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Using the wrong token
&lt;/h3&gt;

&lt;p&gt;Symptom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unauthorized, invalid token, or recipient error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check whether the request used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A LIFF access token&lt;/li&gt;
&lt;li&gt;A MINI App channel access token&lt;/li&gt;
&lt;li&gt;A Service notification token&lt;/li&gt;
&lt;li&gt;A Messaging API channel access token&lt;/li&gt;
&lt;li&gt;A reply token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Token type should be part of your request tracing metadata, but never log the token value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Losing the renewed Service notification token
&lt;/h3&gt;

&lt;p&gt;Symptom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First notification succeeds&lt;/li&gt;
&lt;li&gt;The next notification fails&lt;/li&gt;
&lt;li&gt;Stored &lt;code&gt;remainingCount&lt;/code&gt; does not match the latest response&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cause:&lt;/p&gt;

&lt;p&gt;The application continued using the previous token value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reusing a token concurrently
&lt;/h3&gt;

&lt;p&gt;Symptom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two workers process the same action&lt;/li&gt;
&lt;li&gt;One succeeds and one fails&lt;/li&gt;
&lt;li&gt;The next token state becomes unclear&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;p&gt;Use an action-level lock, idempotency key, and atomic token update.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Service Messages for marketing
&lt;/h3&gt;

&lt;p&gt;Symptom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Template review fails&lt;/li&gt;
&lt;li&gt;Production use is restricted&lt;/li&gt;
&lt;li&gt;Message content no longer matches the approved action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;p&gt;Move promotional communication to an eligible Official Account method and follow its rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treating a push message as a guaranteed alternative
&lt;/h3&gt;

&lt;p&gt;Symptom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Service Message path fails&lt;/li&gt;
&lt;li&gt;The application attempts a push message&lt;/li&gt;
&lt;li&gt;The user is not an eligible Messaging API recipient&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;p&gt;Evaluate recipient eligibility before designing a fallback. The two paths do not have the same reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision tree
&lt;/h2&gt;

&lt;p&gt;Ask these questions in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did the user perform the triggering action inside the LINE MINI App?&lt;/li&gt;
&lt;li&gt;Is the message only a confirmation, action result, or reminder for that action?&lt;/li&gt;
&lt;li&gt;Is the MINI App verified?&lt;/li&gt;
&lt;li&gt;Has the exact template passed review?&lt;/li&gt;
&lt;li&gt;Is a valid Service notification token available?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If all answers are yes, use the Service Message API.&lt;/p&gt;

&lt;p&gt;Otherwise ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Should the sender be the LINE Official Account?&lt;/li&gt;
&lt;li&gt;Is this a reply to an eligible webhook event?&lt;/li&gt;
&lt;li&gt;Is the recipient eligible for push or audience delivery?&lt;/li&gt;
&lt;li&gt;Is the account within its message allowance?&lt;/li&gt;
&lt;li&gt;Which Messaging API method matches the recipient model?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then choose reply, push, multicast, narrowcast, or broadcast as appropriate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Message purpose is defined&lt;/li&gt;
&lt;li&gt;[ ] Triggering user action is identified&lt;/li&gt;
&lt;li&gt;[ ] Sender identity is correct&lt;/li&gt;
&lt;li&gt;[ ] Chat-room destination is understood&lt;/li&gt;
&lt;li&gt;[ ] Token type is explicit&lt;/li&gt;
&lt;li&gt;[ ] Service Message template is approved&lt;/li&gt;
&lt;li&gt;[ ] Promotional content is excluded from Service Messages&lt;/li&gt;
&lt;li&gt;[ ] Renewed notification tokens are stored atomically&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;remainingCount&lt;/code&gt; and expiry are persisted&lt;/li&gt;
&lt;li&gt;[ ] Action-level idempotency is implemented&lt;/li&gt;
&lt;li&gt;[ ] Reply tokens are not stored as permanent addresses&lt;/li&gt;
&lt;li&gt;[ ] Messaging API recipient eligibility is checked&lt;/li&gt;
&lt;li&gt;[ ] Monthly allowance and rate limits are monitored&lt;/li&gt;
&lt;li&gt;[ ] Duplicate cross-path delivery is prevented&lt;/li&gt;
&lt;li&gt;[ ] Both paths correlate through an internal business ID&lt;/li&gt;
&lt;li&gt;[ ] Credentials, queues, and retry policies remain separate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The correct question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which LINE API can send the most messages?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which product, sender identity, trigger, recipient model, and policy match this specific message?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Official references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/line-mini-app/develop/service-messages/" rel="noopener noreferrer"&gt;Sending LINE MINI App Service Messages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/line-mini-app/service/service-operation/" rel="noopener noreferrer"&gt;Service Message conditions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/reference/line-mini-app/" rel="noopener noreferrer"&gt;LINE MINI App API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/sending-messages/" rel="noopener noreferrer"&gt;Sending messages with the Messaging API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/pricing/" rel="noopener noreferrer"&gt;Messaging API pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.unifyport.ai/blog/line-service-messages-vs-messaging-api-mini-app/" rel="noopener noreferrer"&gt;UnifyPort&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was prepared with AI assistance for language and structure, then technically reviewed and verified by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>wenhooks</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One LINE Official Account, Multiple Tools: Webhook and Token Architecture</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Wed, 05 Aug 2026 06:02:41 +0000</pubDate>
      <link>https://dev.to/unifyport/one-line-official-account-multiple-tools-webhook-and-token-architecture-5bi8</link>
      <guid>https://dev.to/unifyport/one-line-official-account-multiple-tools-webhook-and-token-architecture-5bi8</guid>
      <description>&lt;p&gt;A completed WhatsApp Embedded Signup dialog does not mean that a customer is ready to send and receive messages.&lt;/p&gt;

&lt;p&gt;The browser flow may have finished while the backend still has one of these problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The result was attached to the wrong tenant&lt;/li&gt;
&lt;li&gt;The access token belongs to another app&lt;/li&gt;
&lt;li&gt;The wrong WhatsApp Business Account was selected&lt;/li&gt;
&lt;li&gt;The system user lacks the required access&lt;/li&gt;
&lt;li&gt;The phone number is not ready&lt;/li&gt;
&lt;li&gt;The app is not subscribed to WABA webhooks&lt;/li&gt;
&lt;li&gt;The first webhook cannot be routed to the customer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A SaaS application should display &lt;strong&gt;Connected&lt;/strong&gt; only after every backend gate has passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model onboarding as a state machine
&lt;/h2&gt;

&lt;p&gt;Avoid representing onboarding with one Boolean field such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use explicit states instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WhatsAppOnboardingStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;started&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser_finished&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token_validated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;waba_resolved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;access_verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;phone_ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app_subscribed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook_pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verification_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A safe transition path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;started
   ↓
browser_finished
   ↓
token_validated
   ↓
waba_resolved
   ↓
access_verified
   ↓
phone_ready
   ↓
app_subscribed
   ↓
webhook_pending
   ↓
active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each transition should store evidence, not just a timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the acceptance gates
&lt;/h2&gt;

&lt;p&gt;Meta's &lt;a href="https://www.postman.com/meta/whatsapp-business-platform/documentation/du6gzjv/embedded-signup" rel="noopener noreferrer"&gt;official Embedded Signup collection&lt;/a&gt; separates the browser flow from the Graph API work required afterward.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;th&gt;Failure risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Session correlation&lt;/td&gt;
&lt;td&gt;Tenant ID, state, configuration ID&lt;/td&gt;
&lt;td&gt;Assets attached to the wrong customer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token validation&lt;/td&gt;
&lt;td&gt;App ID, scopes, expiry metadata&lt;/td&gt;
&lt;td&gt;Token exists but cannot manage the WABA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WABA resolution&lt;/td&gt;
&lt;td&gt;Exact WABA ID and business mapping&lt;/td&gt;
&lt;td&gt;First list result belongs to another customer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System-user access&lt;/td&gt;
&lt;td&gt;System-user ID and required task&lt;/td&gt;
&lt;td&gt;Later API operations fail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone readiness&lt;/td&gt;
&lt;td&gt;Phone-number ID and onboarding path&lt;/td&gt;
&lt;td&gt;WABA exists but messaging is unavailable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App subscription&lt;/td&gt;
&lt;td&gt;App appears in &lt;code&gt;subscribed_apps&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Meta receives messages but sends no webhook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery proof&lt;/td&gt;
&lt;td&gt;One correctly routed webhook&lt;/td&gt;
&lt;td&gt;Configuration passes without real delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A tenant should remain unavailable until all required gates pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Correlate the browser result with a server session
&lt;/h2&gt;

&lt;p&gt;Create the onboarding session on your server before opening Embedded Signup.&lt;/p&gt;

&lt;p&gt;Store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OnboardingSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;startedByUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;configurationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onboardingPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cloud_api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;coexistence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;consumedAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;state&lt;/code&gt; value should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Random&lt;/li&gt;
&lt;li&gt;Single-use&lt;/li&gt;
&lt;li&gt;Bound to one tenant&lt;/li&gt;
&lt;li&gt;Bound to the initiating user&lt;/li&gt;
&lt;li&gt;Short-lived&lt;/li&gt;
&lt;li&gt;Validated on the server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the browser reports completion:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look up the server session&lt;/li&gt;
&lt;li&gt;Verify the state&lt;/li&gt;
&lt;li&gt;Reject expired sessions&lt;/li&gt;
&lt;li&gt;Reject already-consumed sessions&lt;/li&gt;
&lt;li&gt;Verify that the current user can modify the tenant&lt;/li&gt;
&lt;li&gt;Mark the result as consumed atomically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not trust a tenant ID submitted by the browser if it can be derived from the authenticated server session.&lt;/p&gt;

&lt;p&gt;If the Coexistence flow emits &lt;code&gt;FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING&lt;/code&gt;, treat it as evidence that the dialog finished—not evidence that the backend is active.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Keep credentials out of the browser
&lt;/h2&gt;

&lt;p&gt;Credential exchange and Graph API verification belong on the server.&lt;/p&gt;

&lt;p&gt;Do not place access tokens in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browser storage&lt;/li&gt;
&lt;li&gt;Query strings&lt;/li&gt;
&lt;li&gt;Client-side analytics&lt;/li&gt;
&lt;li&gt;Error-reporting breadcrumbs&lt;/li&gt;
&lt;li&gt;Support screenshots&lt;/li&gt;
&lt;li&gt;Application logs&lt;/li&gt;
&lt;li&gt;Database fields returned by public APIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the credential in a secret manager or encrypted server-side store.&lt;/p&gt;

&lt;p&gt;Persist only the audit metadata your application needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TokenAuditMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tokenType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;grantedScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;issuedAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;lastValidatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-empty token is not proof that it belongs to the expected Meta app or has the permissions required for the selected WABA.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Resolve the intended WABA deterministically
&lt;/h2&gt;

&lt;p&gt;The official collection exposes this endpoint for retrieving client WABAs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /{business-id}/client_whatsapp_business_accounts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A business can return multiple WABAs. Never select the first array element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Unsafe&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match against information captured for the current onboarding session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveWaba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;availableWabas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;expectedWabaId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;availableWabas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedWabaId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expected_waba_not_resolved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the relationship explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TenantWabaMapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;metaBusinessId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;wabaId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sourceSessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no exact match exists, move the tenant to &lt;code&gt;verification_required&lt;/code&gt;. Do not silently attach another WABA.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verify system-user access
&lt;/h2&gt;

&lt;p&gt;Meta documents the following endpoint for checking assigned users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /{waba-id}/assigned_users?business={business-id}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The expected system user is present&lt;/li&gt;
&lt;li&gt;It belongs to the expected business&lt;/li&gt;
&lt;li&gt;It has the task required by your integration&lt;/li&gt;
&lt;li&gt;Your backend credential can perform the required WABA operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not treat any returned system user as sufficient.&lt;/p&gt;

&lt;p&gt;A system user added for a different operational role may not have the access your message or template workflow requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Branch by onboarding path
&lt;/h2&gt;

&lt;p&gt;Standard Cloud API onboarding and WhatsApp Business App Coexistence are not the same phone-number path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Standard Cloud API
&lt;/h3&gt;

&lt;p&gt;Depending on the current onboarding contract, the backend may need to register the phone number and verify that its status permits messaging.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coexistence
&lt;/h3&gt;

&lt;p&gt;Coexistence uses a number already connected to the WhatsApp Business app.&lt;/p&gt;

&lt;p&gt;Do not automatically repeat the standard registration operation. Instead, validate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The number returned by the Coexistence flow&lt;/li&gt;
&lt;li&gt;Its current status&lt;/li&gt;
&lt;li&gt;Its WABA association&lt;/li&gt;
&lt;li&gt;The intended synchronization behavior&lt;/li&gt;
&lt;li&gt;The supported message-history boundary&lt;/li&gt;
&lt;li&gt;Which surface owns each business operation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the path in your tenant configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WhatsAppConnection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;wabaId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;phoneNumberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cloud_api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;coexistence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verification_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents later jobs from applying Cloud API assumptions to a Coexistence number.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Subscribe the app to the WABA
&lt;/h2&gt;

&lt;p&gt;A valid WABA and phone number do not prove that webhook delivery is configured.&lt;/p&gt;

&lt;p&gt;Meta's official collection uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /{waba-id}/subscribed_apps
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the result afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /{waba-id}/subscribed_apps
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expected Meta app must appear in the returned subscription list.&lt;/p&gt;

&lt;p&gt;Treat subscription as a separate gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyAppSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;whatsapp_business_api_data&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;expectedAppId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscribed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;whatsapp_business_api_data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedAppId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;subscribed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;waba_app_subscription_missing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registration can succeed while subscription fails. Subscription can also exist while the selected phone number is not operational.&lt;/p&gt;

&lt;p&gt;Do not combine these conditions into one status.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Prove one real webhook delivery
&lt;/h2&gt;

&lt;p&gt;Configuration reads are necessary but not sufficient.&lt;/p&gt;

&lt;p&gt;Before marking the tenant active, require one controlled webhook to reach a production-equivalent receiver.&lt;/p&gt;

&lt;p&gt;The acceptance path should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Controlled WhatsApp message
          ↓
Meta webhook delivery
          ↓
Signature verification
          ↓
WABA and phone-number lookup
          ↓
Tenant resolution
          ↓
Idempotency check
          ↓
Durable event storage
          ↓
Successful acknowledgement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a Meta webhook, verify the signature against the raw request body before parsing or routing it.&lt;/p&gt;

&lt;p&gt;An illustrative Node.js helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyMetaSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;appSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;appSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never log the app secret, token, or complete sensitive webhook payload while debugging verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Route by provider assets, not browser state
&lt;/h2&gt;

&lt;p&gt;The webhook receiver must resolve the tenant from trusted provider identifiers.&lt;/p&gt;

&lt;p&gt;Relevant identifiers can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WABA ID&lt;/li&gt;
&lt;li&gt;Phone-number ID&lt;/li&gt;
&lt;li&gt;Meta app ID&lt;/li&gt;
&lt;li&gt;Internal connection ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not route a production webhook using a tenant ID previously stored in browser state without verifying its asset mapping.&lt;/p&gt;

&lt;p&gt;A routing table can look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;whatsapp_connections&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;meta_app_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;waba_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;phone_number_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;onboarding_mode&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta_app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waba_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone_number_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the mapping is missing or ambiguous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not guess&lt;/li&gt;
&lt;li&gt;Do not assign the event to the most recently onboarded tenant&lt;/li&gt;
&lt;li&gt;Store it in a restricted reconciliation queue&lt;/li&gt;
&lt;li&gt;Alert the integration owner&lt;/li&gt;
&lt;li&gt;Keep the tenant out of &lt;code&gt;active&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Make webhook processing idempotent
&lt;/h2&gt;

&lt;p&gt;Webhook delivery can be retried.&lt;/p&gt;

&lt;p&gt;Choose an idempotency key from stable provider fields appropriate to the event type. For inbound messages, the provider message ID is usually part of that identity.&lt;/p&gt;

&lt;p&gt;Store the event before running slow business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verify signature
      ↓
Resolve tenant
      ↓
Insert event if absent
      ↓
Return success
      ↓
Process asynchronously
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A duplicate should not create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two conversations&lt;/li&gt;
&lt;li&gt;Two automated replies&lt;/li&gt;
&lt;li&gt;Two billing records&lt;/li&gt;
&lt;li&gt;Two workflow executions&lt;/li&gt;
&lt;li&gt;Two customer notifications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Operational readiness means that both the first delivery and a repeated delivery are safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Treat credit-line attachment as conditional
&lt;/h2&gt;

&lt;p&gt;The official Embedded Signup collection includes credit-line sharing for provider-paid arrangements.&lt;/p&gt;

&lt;p&gt;That does not make credit-line attachment a universal readiness gate.&lt;/p&gt;

&lt;p&gt;Apply it only when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your business owns the billing relationship with Meta&lt;/li&gt;
&lt;li&gt;Your partner model requires credit sharing&lt;/li&gt;
&lt;li&gt;The customer WABA is expected to use that credit line&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For customer-paid or other supported billing arrangements, document the appropriate billing check separately.&lt;/p&gt;

&lt;p&gt;Do not block every tenant on a credit-line operation that does not belong to its commercial model.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Separate platform approval from operational readiness
&lt;/h2&gt;

&lt;p&gt;This checklist does not prove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Meta business eligibility&lt;/li&gt;
&lt;li&gt;App Review approval&lt;/li&gt;
&lt;li&gt;Advanced Access approval&lt;/li&gt;
&lt;li&gt;Display-name approval&lt;/li&gt;
&lt;li&gt;Number quality&lt;/li&gt;
&lt;li&gt;Template approval&lt;/li&gt;
&lt;li&gt;Messaging-limit tier&lt;/li&gt;
&lt;li&gt;Policy compliance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are separate platform states.&lt;/p&gt;

&lt;p&gt;Maintain them independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WhatsAppReadiness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;embeddedSignup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;appReview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;wabaAccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;phoneStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;webhookSubscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;deliveryTest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;passed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful test tenant also does not prove that every customer configuration will work. Keep the verification process repeatable for every onboarding session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommended failure states
&lt;/h2&gt;

&lt;p&gt;Return actionable states instead of a generic connection error.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Next action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session_mismatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser result cannot be correlated&lt;/td&gt;
&lt;td&gt;Restart onboarding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token_invalid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Credential validation failed&lt;/td&gt;
&lt;td&gt;Repeat credential exchange&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waba_not_resolved&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expected WABA was not found&lt;/td&gt;
&lt;td&gt;Review business selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;system_user_missing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required access is absent&lt;/td&gt;
&lt;td&gt;Assign or repair system user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_not_ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Phone path is incomplete&lt;/td&gt;
&lt;td&gt;Follow path-specific recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subscription_missing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;App is not subscribed&lt;/td&gt;
&lt;td&gt;Subscribe and verify again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webhook_not_received&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No delivery proof exists&lt;/td&gt;
&lt;td&gt;Inspect webhook and routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;routing_ambiguous&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asset mapping matches multiple tenants&lt;/td&gt;
&lt;td&gt;Stop and reconcile mappings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These states make support, retry behavior, and audit logs much easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final post-onboarding checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Server-side onboarding session exists&lt;/li&gt;
&lt;li&gt;[ ] State is valid, single-use, and tenant-bound&lt;/li&gt;
&lt;li&gt;[ ] Browser result has been consumed exactly once&lt;/li&gt;
&lt;li&gt;[ ] Credentials remain server-side&lt;/li&gt;
&lt;li&gt;[ ] Token app, permissions, and expiry were validated&lt;/li&gt;
&lt;li&gt;[ ] Exact WABA was resolved&lt;/li&gt;
&lt;li&gt;[ ] WABA was not selected by array position&lt;/li&gt;
&lt;li&gt;[ ] System-user access was verified&lt;/li&gt;
&lt;li&gt;[ ] Standard and Coexistence paths are separated&lt;/li&gt;
&lt;li&gt;[ ] Phone-number ID is stored&lt;/li&gt;
&lt;li&gt;[ ] Phone status is ready for the selected path&lt;/li&gt;
&lt;li&gt;[ ] Expected app appears in &lt;code&gt;subscribed_apps&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Billing checks match the commercial model&lt;/li&gt;
&lt;li&gt;[ ] One real webhook reached the receiver&lt;/li&gt;
&lt;li&gt;[ ] Webhook signature was verified&lt;/li&gt;
&lt;li&gt;[ ] WABA and phone number resolved to one tenant&lt;/li&gt;
&lt;li&gt;[ ] Duplicate delivery is safe&lt;/li&gt;
&lt;li&gt;[ ] Failure states are recoverable&lt;/li&gt;
&lt;li&gt;[ ] Only then is the tenant marked &lt;code&gt;active&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser dialog finishing is a user-interface milestone.&lt;/p&gt;

&lt;p&gt;Operational readiness requires a verified chain from the server-side onboarding session to the correct WABA, system user, phone number, app subscription, tenant mapping, and real webhook delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postman.com/meta/whatsapp-business-platform/documentation/du6gzjv/embedded-signup" rel="noopener noreferrer"&gt;Meta Embedded Signup collection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup/version-4" rel="noopener noreferrer"&gt;Meta Embedded Signup v4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/documentation/business-messaging/whatsapp/embedded-signup/onboarding-business-app-users" rel="noopener noreferrer"&gt;Onboarding WhatsApp Business app users&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.unifyport.ai/blog/whatsapp-embedded-signup-post-onboarding-verification-checklist/" rel="noopener noreferrer"&gt;UnifyPort&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was prepared with AI assistance for language and structure, then technically reviewed and verified by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webhooks</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One LINE Official Account, Multiple Tools: Webhook and Token Architecture</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Tue, 04 Aug 2026 03:10:04 +0000</pubDate>
      <link>https://dev.to/unifyport/one-line-official-account-multiple-tools-webhook-and-token-architecture-3p5l</link>
      <guid>https://dev.to/unifyport/one-line-official-account-multiple-tools-webhook-and-token-architecture-3p5l</guid>
      <description>&lt;p&gt;A single LINE Official Account can use multiple Messaging API tools.&lt;/p&gt;

&lt;p&gt;For example, one account might connect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A customer-support platform&lt;/li&gt;
&lt;li&gt;A campaign sender&lt;/li&gt;
&lt;li&gt;A rich-menu manager&lt;/li&gt;
&lt;li&gt;An analytics service&lt;/li&gt;
&lt;li&gt;An internal automation system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But these tools do not receive isolated LINE channels. They share one Messaging API channel, one webhook URL, channel access-token limits, API rate limits, and feature-specific quotas.&lt;/p&gt;

&lt;p&gt;That makes adding another tool an architecture change—not just another OAuth or API-key setup step.&lt;/p&gt;

&lt;p&gt;This guide explains how to share the channel without accidentally disabling an existing tool or losing inbound messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand the shared boundary
&lt;/h2&gt;

&lt;p&gt;LINE's &lt;a href="https://developers.line.biz/en/tips/2026/07/23/using-multiple-api-tools/" rel="noopener noreferrer"&gt;official multiple-tools guidance&lt;/a&gt; confirms that multiple tools can call the Messaging API through one LINE Official Account.&lt;/p&gt;

&lt;p&gt;However, only one Messaging API channel can be linked to the account.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shared resource&lt;/th&gt;
&lt;th&gt;LINE constraint&lt;/th&gt;
&lt;th&gt;Operational risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Messaging API channel&lt;/td&gt;
&lt;td&gt;One channel per Official Account&lt;/td&gt;
&lt;td&gt;All tools share configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook URL&lt;/td&gt;
&lt;td&gt;One URL per channel&lt;/td&gt;
&lt;td&gt;A new tool can replace the existing receiver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Channel access tokens&lt;/td&gt;
&lt;td&gt;Issuance limits vary by token type&lt;/td&gt;
&lt;td&gt;Rotation can disable another tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API rate limits&lt;/td&gt;
&lt;td&gt;Applied per endpoint and channel&lt;/td&gt;
&lt;td&gt;One tool can throttle another&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging quota&lt;/td&gt;
&lt;td&gt;Shared by the account and plan&lt;/td&gt;
&lt;td&gt;Campaign traffic can affect support traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich menus and audiences&lt;/td&gt;
&lt;td&gt;Channel-level limits&lt;/td&gt;
&lt;td&gt;Tools can overwrite or exhaust shared resources&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before connecting another tool, identify exactly which shared resources it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create an integration inventory
&lt;/h2&gt;

&lt;p&gt;Maintain a manifest for every system using the channel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;support-platform&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customer-support-team&lt;/span&gt;
    &lt;span class="na"&gt;features&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;receive-webhooks&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;reply-messages&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;push-messages&lt;/span&gt;
    &lt;span class="na"&gt;token_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2.1&lt;/span&gt;
    &lt;span class="na"&gt;owns_webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;campaign-service&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;marketing-operations&lt;/span&gt;
    &lt;span class="na"&gt;features&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;broadcast-messages&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;audience-management&lt;/span&gt;
    &lt;span class="na"&gt;token_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2.1&lt;/span&gt;
    &lt;span class="na"&gt;owns_webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rich-menu-manager&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;product-team&lt;/span&gt;
    &lt;span class="na"&gt;features&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rich-menu-management&lt;/span&gt;
    &lt;span class="na"&gt;token_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stateless&lt;/span&gt;
    &lt;span class="na"&gt;owns_webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each tool, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Business owner&lt;/li&gt;
&lt;li&gt;Technical owner&lt;/li&gt;
&lt;li&gt;Messaging API endpoints&lt;/li&gt;
&lt;li&gt;Required webhook events&lt;/li&gt;
&lt;li&gt;Token type&lt;/li&gt;
&lt;li&gt;Token issuer&lt;/li&gt;
&lt;li&gt;Expiration and renewal procedure&lt;/li&gt;
&lt;li&gt;Rich-menu or audience ownership&lt;/li&gt;
&lt;li&gt;Expected request volume&lt;/li&gt;
&lt;li&gt;Emergency-disable procedure&lt;/li&gt;
&lt;li&gt;Rollback procedure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not connect a tool until these responsibilities are assigned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose channel access tokens carefully
&lt;/h2&gt;

&lt;p&gt;LINE currently supports four channel access-token types.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token type&lt;/th&gt;
&lt;th&gt;Validity&lt;/th&gt;
&lt;th&gt;Issuance limit per channel&lt;/th&gt;
&lt;th&gt;Limit behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Long-lived&lt;/td&gt;
&lt;td&gt;No fixed expiration&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Reissuing invalidates the active token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short-lived&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Issuing beyond the limit revokes the oldest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2.1&lt;/td&gt;
&lt;td&gt;Up to 30 days&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Additional issuance is rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stateless&lt;/td&gt;
&lt;td&gt;15 minutes&lt;/td&gt;
&lt;td&gt;No stated count limit&lt;/td&gt;
&lt;td&gt;Cannot be revoked after issuance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The exact behavior is documented in LINE's &lt;a href="https://developers.line.biz/en/docs/basics/channel-access-token/" rel="noopener noreferrer"&gt;channel access-token guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The dangerous case is a shared long-lived token.&lt;/p&gt;

&lt;p&gt;If Tool A and Tool B both use the same long-lived token, reissuing it for Tool B immediately affects Tool A.&lt;/p&gt;

&lt;p&gt;A safer policy is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Issue credentials per team or tool&lt;/li&gt;
&lt;li&gt;Prefer expiring tokens when the vendor supports them&lt;/li&gt;
&lt;li&gt;Store tokens in a secret manager&lt;/li&gt;
&lt;li&gt;Record the token type and owner&lt;/li&gt;
&lt;li&gt;Automate renewal before expiration&lt;/li&gt;
&lt;li&gt;Test the new token before retiring the old one&lt;/li&gt;
&lt;li&gt;Revoke credentials when a tool is removed&lt;/li&gt;
&lt;li&gt;Never copy one team's production token into an unrelated system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not log token values, even during migration troubleshooting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep one webhook owner
&lt;/h2&gt;

&lt;p&gt;A Messaging API channel supports one webhook URL.&lt;/p&gt;

&lt;p&gt;LINE cannot directly deliver the same event to two independent URLs. If a new vendor changes the webhook URL, the current receiver stops receiving events.&lt;/p&gt;

&lt;p&gt;Use this decision table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;New tool requirement&lt;/th&gt;
&lt;th&gt;Recommended approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sends messages only&lt;/td&gt;
&lt;td&gt;Give it an appropriate token; do not change the webhook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manages rich menus only&lt;/td&gt;
&lt;td&gt;Leave the webhook owner unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replaces the current inbox&lt;/td&gt;
&lt;td&gt;Perform a controlled webhook migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs a subset of inbound events&lt;/td&gt;
&lt;td&gt;Forward verified events from the existing receiver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple tools need every event&lt;/td&gt;
&lt;td&gt;Use one receiver and fan out internally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor requires direct ownership&lt;/td&gt;
&lt;td&gt;Choose one inbound owner or use another Official Account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The stable architecture is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LINE Platform
      ↓
Single webhook endpoint
      ↓
Signature verification
      ↓
Durable event inbox
      ↓
Internal event router
   ↙       ↓       ↘
Support  Analytics  Automation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify the signature before parsing
&lt;/h2&gt;

&lt;p&gt;LINE signs webhook requests using the channel secret.&lt;/p&gt;

&lt;p&gt;Verification must use the raw request body. Do not parse and reserialize the JSON before calculating the signature.&lt;/p&gt;

&lt;p&gt;An illustrative Node.js receiver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channelSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINE_CHANNEL_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;channelSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LINE_CHANNEL_SECRET is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyLineSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/line/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-line-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifyLineSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;channelSecret&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;eventInbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;occurredAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify before processing&lt;/li&gt;
&lt;li&gt;Use a timing-safe comparison&lt;/li&gt;
&lt;li&gt;Preserve the raw body&lt;/li&gt;
&lt;li&gt;Do not log secrets or reply tokens&lt;/li&gt;
&lt;li&gt;Store the event before asynchronous fan-out&lt;/li&gt;
&lt;li&gt;Return a successful response promptly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LINE recommends asynchronous webhook processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplicate before fan-out
&lt;/h2&gt;

&lt;p&gt;Webhook redelivery can cause the same event to arrive more than once.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;webhookEventId&lt;/code&gt; as the idempotency key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;line_webhook_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;webhook_event_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processing_status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiver should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the signature&lt;/li&gt;
&lt;li&gt;Insert the event with a unique &lt;code&gt;webhookEventId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Treat a duplicate-key result as an accepted duplicate&lt;/li&gt;
&lt;li&gt;Return &lt;code&gt;2xx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Let a worker process newly inserted events&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not let each downstream tool independently deduplicate the original webhook. Deduplication belongs at the shared receiving boundary.&lt;/p&gt;

&lt;p&gt;Webhook redelivery can also change arrival order. Use the event &lt;code&gt;timestamp&lt;/code&gt; to understand event sequence when order matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give reply-token ownership to one component
&lt;/h2&gt;

&lt;p&gt;Reply tokens create an additional concurrency problem.&lt;/p&gt;

&lt;p&gt;LINE reply tokens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can only be used once&lt;/li&gt;
&lt;li&gt;Should be used as soon as possible&lt;/li&gt;
&lt;li&gt;Must normally be used within one minute of receiving the webhook&lt;/li&gt;
&lt;li&gt;Cannot be safely consumed by multiple independent tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the same event is forwarded to a support system and an automation system, both systems must not attempt to use the reply token.&lt;/p&gt;

&lt;p&gt;Assign one reply coordinator.&lt;/p&gt;

&lt;p&gt;Downstream services can return a reply intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ReplyIntent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;webhookEventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;automation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coordinator decides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether the event should receive a reply&lt;/li&gt;
&lt;li&gt;Which tool has priority&lt;/li&gt;
&lt;li&gt;Whether a human response suppresses automation&lt;/li&gt;
&lt;li&gt;Whether multiple messages should be combined&lt;/li&gt;
&lt;li&gt;Whether the reply token has already been consumed&lt;/li&gt;
&lt;li&gt;Whether a later response must use a push message instead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This prevents duplicate or conflicting replies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate inbound and outbound ownership
&lt;/h2&gt;

&lt;p&gt;A tool that receives webhooks does not automatically need to own every outbound message.&lt;/p&gt;

&lt;p&gt;Define ownership by operation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Suggested owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Webhook signature verification&lt;/td&gt;
&lt;td&gt;Ingress service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable event storage&lt;/td&gt;
&lt;td&gt;Ingress service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply-token consumption&lt;/td&gt;
&lt;td&gt;Reply coordinator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human support replies&lt;/td&gt;
&lt;td&gt;Support system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled campaigns&lt;/td&gt;
&lt;td&gt;Campaign platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich menus&lt;/td&gt;
&lt;td&gt;Product or marketing owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audiences&lt;/td&gt;
&lt;td&gt;Marketing operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Channel token issuance&lt;/td&gt;
&lt;td&gt;Platform or security team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared quota monitoring&lt;/td&gt;
&lt;td&gt;Platform operations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One tool should not silently change resources owned by another.&lt;/p&gt;

&lt;p&gt;For example, a support-platform installation should not replace production rich menus unless rich-menu management is explicitly part of its approved scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Budget shared rate limits
&lt;/h2&gt;

&lt;p&gt;Messaging API rate limits are applied per API function and per channel, regardless of which tool or IP address sends the request.&lt;/p&gt;

&lt;p&gt;This means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tool A traffic
+ Tool B traffic
+ Tool C traffic
= Channel traffic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monitor aggregate usage rather than isolated vendor dashboards.&lt;/p&gt;

&lt;p&gt;Track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Requests per endpoint&lt;/li&gt;
&lt;li&gt;&lt;code&gt;429 Too Many Requests&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Monthly message usage&lt;/li&gt;
&lt;li&gt;Broadcast volume&lt;/li&gt;
&lt;li&gt;Push-message volume&lt;/li&gt;
&lt;li&gt;Rich-menu count&lt;/li&gt;
&lt;li&gt;Audience count&lt;/li&gt;
&lt;li&gt;Statistics-unit usage&lt;/li&gt;
&lt;li&gt;Per-tool error rate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not blindly retry every &lt;code&gt;429&lt;/code&gt;. A coordinated retry policy should include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Endpoint-aware backoff&lt;/li&gt;
&lt;li&gt;Jitter&lt;/li&gt;
&lt;li&gt;Maximum attempts&lt;/li&gt;
&lt;li&gt;Message-expiration rules&lt;/li&gt;
&lt;li&gt;Shared concurrency limits&lt;/li&gt;
&lt;li&gt;Alerting when one tool consumes unusual capacity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reserve capacity for critical support replies before launching a large campaign.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test unknown webhook events
&lt;/h2&gt;

&lt;p&gt;Adding or enabling a tool can change which features are used through the shared channel.&lt;/p&gt;

&lt;p&gt;Your current receiver must safely handle events it does not consume.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;routeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LineWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;messageQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;follow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unfollow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;accountQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;automationQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;unknownEventQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unknown event should be observable, but it should not crash the entire webhook request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration checklist for a new tool
&lt;/h2&gt;

&lt;p&gt;Before installation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Export the current webhook URL&lt;/li&gt;
&lt;li&gt;Identify the current token types&lt;/li&gt;
&lt;li&gt;List all token consumers&lt;/li&gt;
&lt;li&gt;Record current rich menus and audiences&lt;/li&gt;
&lt;li&gt;Measure normal API traffic&lt;/li&gt;
&lt;li&gt;Confirm whether the new tool needs inbound events&lt;/li&gt;
&lt;li&gt;Confirm which settings its installer changes&lt;/li&gt;
&lt;li&gt;Prepare the rollback configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During installation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use a tool-specific credential&lt;/li&gt;
&lt;li&gt;Prevent unapproved webhook replacement&lt;/li&gt;
&lt;li&gt;Test in a controlled environment&lt;/li&gt;
&lt;li&gt;Send one inbound message&lt;/li&gt;
&lt;li&gt;Confirm exactly one durable event&lt;/li&gt;
&lt;li&gt;Confirm the correct downstream consumers receive it&lt;/li&gt;
&lt;li&gt;Verify only one component uses the reply token&lt;/li&gt;
&lt;li&gt;Test one supported outbound action&lt;/li&gt;
&lt;li&gt;Monitor rate-limit and authentication errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After installation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recheck the webhook URL&lt;/li&gt;
&lt;li&gt;Recheck existing tools&lt;/li&gt;
&lt;li&gt;Confirm campaigns and support replies still work&lt;/li&gt;
&lt;li&gt;Confirm rich menus were not replaced&lt;/li&gt;
&lt;li&gt;Review message usage&lt;/li&gt;
&lt;li&gt;Rotate temporary credentials&lt;/li&gt;
&lt;li&gt;Record the final configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prepare a rollback procedure
&lt;/h2&gt;

&lt;p&gt;A rollback should specify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rollback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;previous_webhook_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;STORED_IN_SECURE_CONFIGURATION&lt;/span&gt;
  &lt;span class="na"&gt;affected_tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;support-platform&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;campaign-service&lt;/span&gt;
  &lt;span class="na"&gt;token_action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;revoke-new-tool-token&lt;/span&gt;
  &lt;span class="na"&gt;verification&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;send-controlled-user-message&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;confirm-single-webhook-record&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;confirm-support-reply&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;confirm-existing-rich-menu&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not make the first production failure the first time the rollback is tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final architecture checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every connected tool has an owner&lt;/li&gt;
&lt;li&gt;[ ] Messaging API features are inventoried per tool&lt;/li&gt;
&lt;li&gt;[ ] Token type and renewal owner are documented&lt;/li&gt;
&lt;li&gt;[ ] Tools do not share one long-lived token&lt;/li&gt;
&lt;li&gt;[ ] The single webhook owner is explicit&lt;/li&gt;
&lt;li&gt;[ ] Webhook signatures are verified against the raw body&lt;/li&gt;
&lt;li&gt;[ ] Events are stored before asynchronous fan-out&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;webhookEventId&lt;/code&gt; is used for deduplication&lt;/li&gt;
&lt;li&gt;[ ] Reply tokens have one coordinator&lt;/li&gt;
&lt;li&gt;[ ] Unknown webhook events remain observable&lt;/li&gt;
&lt;li&gt;[ ] Rich-menu and audience ownership is documented&lt;/li&gt;
&lt;li&gt;[ ] Rate limits are monitored at channel level&lt;/li&gt;
&lt;li&gt;[ ] Monthly message capacity is shared deliberately&lt;/li&gt;
&lt;li&gt;[ ] A controlled end-to-end test has passed&lt;/li&gt;
&lt;li&gt;[ ] The previous webhook configuration can be restored&lt;/li&gt;
&lt;li&gt;[ ] Removing a tool revokes its credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Multiple tools can safely share one LINE Official Account, but only when the shared channel is treated as production infrastructure.&lt;/p&gt;

&lt;p&gt;The most important rule is simple: receive once, verify once, store once, and coordinate everything that happens afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/tips/2026/07/23/using-multiple-api-tools/" rel="noopener noreferrer"&gt;Using the Messaging API from multiple tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/basics/channel-access-token/" rel="noopener noreferrer"&gt;Channel access-token types and limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/receiving-messages/" rel="noopener noreferrer"&gt;Receiving webhook events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/messaging-api/verify-webhook-signature/" rel="noopener noreferrer"&gt;Webhook signature verification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/reference/messaging-api/#rate-limits" rel="noopener noreferrer"&gt;Messaging API rate limits&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.unifyport.ai/blog/line-messaging-api-multiple-tools-one-official-account/" rel="noopener noreferrer"&gt;UnifyPort&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was prepared with AI assistance for language and structure, then technically reviewed and verified by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webhooks</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
