<?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).</description>
    <link>https://dev.to/unifyport</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%2Forganization%2Fprofile_image%2F14080%2F2a631ff9-0438-4d46-95a9-84c1854130b1.jpeg</url>
      <title>DEV Community: UnifyPort</title>
      <link>https://dev.to/unifyport</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/unifyport"/>
    <language>en</language>
    <item>
      <title>Media Send Accepted but Not Delivered? A URL-First Debugging Guide</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Fri, 09 Oct 2026 06:02:18 +0000</pubDate>
      <link>https://dev.to/unifyport/media-send-accepted-but-not-delivered-a-url-first-debugging-guide-ipp</link>
      <guid>https://dev.to/unifyport/media-send-accepted-but-not-delivered-a-url-first-debugging-guide-ipp</guid>
      <description>&lt;p&gt;Sending media through an API looks simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Choose a file
Build JSON
POST the request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, failures usually happen across several separate boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Media source
    ↓
Request validation
    ↓
Account readiness
    ↓
Provider acceptance
    ↓
Delivery
    ↓
Read receipt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file opening in your browser does not prove that a remote sender can retrieve it. An API response with &lt;code&gt;status: accepted&lt;/code&gt; does not prove that the recipient received it. A timed-out request does not prove that nothing happened.&lt;/p&gt;

&lt;p&gt;This guide shows how to build and troubleshoot URL-based media sends with UnifyPort without unsafe retries or incorrect delivery claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the sending contract
&lt;/h2&gt;

&lt;p&gt;UnifyPort sends media 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/messages
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supported media message types include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;image
video
audio
document
file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provider support still varies. A shared endpoint does not mean every connected channel supports every media type.&lt;/p&gt;

&lt;p&gt;A URL-based image 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;"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;"to"&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;"conversation_42"&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;"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;"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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://media.example.com/orders/order-42.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"caption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your order is ready"&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 media source can use a documented field 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;message.url
message.file_url
message.file_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not provide conflicting source fields unless the API explicitly documents their precedence. Start with one source and keep the request unambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  A local filename is not a media URL
&lt;/h2&gt;

&lt;p&gt;This is not remotely accessible:&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;"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;"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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/Users/alice/Desktop/photo.jpg"&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;Neither is 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;"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;"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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./uploads/photo.jpg"&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;Those paths only have meaning on your machine or application server.&lt;/p&gt;

&lt;p&gt;A remote media sender needs an absolute HTTP or HTTPS URL:&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;"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;"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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://media.example.com/uploads/photo.jpg"&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;A &lt;code&gt;data:&lt;/code&gt; URL is not a substitute for a documented remote media source.&lt;/p&gt;

&lt;p&gt;If the file begins locally, first place it in an approved storage system and obtain a controlled URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Works in my browser” is not enough
&lt;/h2&gt;

&lt;p&gt;A browser may successfully open a URL because it has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;login cookies;&lt;/li&gt;
&lt;li&gt;a cached session;&lt;/li&gt;
&lt;li&gt;VPN access;&lt;/li&gt;
&lt;li&gt;internal DNS;&lt;/li&gt;
&lt;li&gt;a previously completed redirect;&lt;/li&gt;
&lt;li&gt;credentials stored by an extension.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sender retrieving the media does not inherit your browser session.&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;https://app.example.com/download/invoice-42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may return the PDF for your authenticated browser but return an HTML login page to an unauthenticated client.&lt;/p&gt;

&lt;p&gt;Before sending, test the URL from a separate trusted environment without browser cookies.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;the final HTTP status;&lt;/li&gt;
&lt;li&gt;redirect behavior;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Content-Type&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;response size;&lt;/li&gt;
&lt;li&gt;whether the response contains media bytes or HTML;&lt;/li&gt;
&lt;li&gt;URL expiration;&lt;/li&gt;
&lt;li&gt;DNS and network accessibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not perform unrestricted server-side fetches against arbitrary user-provided URLs. Use approved storage origins or a carefully designed allowlist to avoid introducing an SSRF vulnerability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use controlled media storage
&lt;/h2&gt;

&lt;p&gt;A production media source should have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTPS;&lt;/li&gt;
&lt;li&gt;a known storage origin;&lt;/li&gt;
&lt;li&gt;a predictable content type;&lt;/li&gt;
&lt;li&gt;sufficient URL validity;&lt;/li&gt;
&lt;li&gt;narrowly scoped access;&lt;/li&gt;
&lt;li&gt;no credentials embedded in the URL authority;&lt;/li&gt;
&lt;li&gt;logs that redact sensitive query strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Signed URLs are useful, but their expiration must account for asynchronous retrieval.&lt;/p&gt;

&lt;p&gt;A URL that works during request construction may expire while the message waits in a queue.&lt;/p&gt;

&lt;p&gt;There is no universal fetch deadline that applies to every provider and delivery path. Choose an expiration window that matches your own queueing and delivery expectations, then monitor failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a minimal request
&lt;/h2&gt;

&lt;p&gt;A small JavaScript helper can validate the application-controlled fields:&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;buildMediaRequest&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;conversation&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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;caption&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;mediaTypes&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image&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;video&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;audio&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;document&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;file&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;accountId&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;accountId 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="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;conversation&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;conversation&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;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;conversation id and type are 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="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;mediaTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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;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;`Unsupported media type: &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="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;source&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;http:&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;https:&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&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;Media source must use HTTP or HTTPS&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&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;Do not embed credentials in the media URL&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;message&lt;/span&gt; &lt;span class="o"&gt;=&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;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;caption&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;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;audio&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;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;Caption is not valid for this audio 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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;caption&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&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;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;caption must be a string&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="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;caption&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;caption&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;account_id&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;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;type&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;type&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helper validates syntax. It does not prove that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the URL is remotely reachable;&lt;/li&gt;
&lt;li&gt;the bytes are valid media;&lt;/li&gt;
&lt;li&gt;the account is connected;&lt;/li&gt;
&lt;li&gt;the provider supports the type;&lt;/li&gt;
&lt;li&gt;the recipient will receive the message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those require separate checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send from the server
&lt;/h2&gt;

&lt;p&gt;Keep the API key out of browser 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestBody&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.unifyport.ai/v1/messages&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;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;requestBody&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;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;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;`&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;body&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;body&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 API key, signed media URL, or unnecessary recipient data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not copy inbound attachments into outbound requests
&lt;/h2&gt;

&lt;p&gt;Inbound media and outbound media use different contracts.&lt;/p&gt;

&lt;p&gt;An inbound event may contain fields 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;"attachments"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/temporary-download"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mimetype"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/jpeg"&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;That object is not automatically a valid outbound &lt;code&gt;message&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The locator may also be temporary or provider-specific.&lt;/p&gt;

&lt;p&gt;Build a new outbound request using the documented send fields:&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;"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;"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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://approved-storage.example.com/image.jpg"&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;If an inbound locator expired, recover the file through the provider’s documented download flow, place it in approved storage, and then construct the outbound request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check provider support before showing the control
&lt;/h2&gt;

&lt;p&gt;Do not let users select a media type that the connected provider cannot send.&lt;/p&gt;

&lt;p&gt;Your application should evaluate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;provider
account mode
message type
current capability matrix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A conceptual capability check might look like:&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;canSendMedia&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="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;capabilities&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;capabilities&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="nx"&gt;send&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;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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 a provider returns:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;do not retry the unchanged request.&lt;/p&gt;

&lt;p&gt;Disable the control or offer a supported fallback.&lt;/p&gt;

&lt;p&gt;A unified API reduces integration differences, but it does not erase provider capabilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check account readiness separately
&lt;/h2&gt;

&lt;p&gt;Authorization does not always mean the messaging runtime is ready.&lt;/p&gt;

&lt;p&gt;Before enabling automated sends, distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Account exists
Account is authorized
Runtime is connected
Provider supports the action
Recipient is valid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An error 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;provider_not_ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;should trigger account-state diagnosis, not immediate reauthorization or blind retries.&lt;/p&gt;

&lt;p&gt;Inspect the account’s authentication and runtime state, then decide whether the connection needs recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accepted is not delivered
&lt;/h2&gt;

&lt;p&gt;An accepted response means the API accepted the request for processing.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The provider delivered the file
The recipient received the file
The recipient opened the file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Track distinct states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;created
submitted
accepted
delivered
read
failed
unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;outboundOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send_order_42_image_1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accepted&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_3003&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;req_91c4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;deliveredAt&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;readAt&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;Only supported delivery evidence should advance the record to &lt;code&gt;delivered&lt;/code&gt; or &lt;code&gt;read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not every provider emits every receipt. Missing confirmation should remain &lt;code&gt;unknown&lt;/code&gt;, not automatically become &lt;code&gt;failed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts create uncertain outcomes
&lt;/h2&gt;

&lt;p&gt;A request timeout does not prove that the provider rejected the message.&lt;/p&gt;

&lt;p&gt;This is unsafe:&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;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;sendMedia&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="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendMedia&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request may have succeeded after your client stopped waiting. The retry can produce a duplicate message.&lt;/p&gt;

&lt;p&gt;Instead, persist an outbound operation before sending:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createOutboundOperation&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="na"&gt;mediaUrlReference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order-42-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submitting&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;Then record the result:&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;try&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;sendMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestBody&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;markAccepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&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="na"&gt;messageId&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;data&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="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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isDefinitiveRejection&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;await&lt;/span&gt; &lt;span class="nf"&gt;markFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&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;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;else&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;markOutcomeUnknown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&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;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;An unknown result needs reconciliation or operator review. It should not automatically trigger another irreversible send.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep diagnostic IDs for support
&lt;/h2&gt;

&lt;p&gt;Save the returned message identifier and &lt;code&gt;request_id&lt;/code&gt; when available.&lt;/p&gt;

&lt;p&gt;They help correlate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;client logs;&lt;/li&gt;
&lt;li&gt;API responses;&lt;/li&gt;
&lt;li&gt;provider processing;&lt;/li&gt;
&lt;li&gt;support investigations;&lt;/li&gt;
&lt;li&gt;webhook receipts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;request_id&lt;/code&gt; is a tracing identifier, not an idempotency token.&lt;/p&gt;

&lt;p&gt;Do not reuse it as proof that two send attempts are the same business operation.&lt;/p&gt;

&lt;p&gt;Your own stable operation ID should represent intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;send-media:order-42:customer-copy:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Debug the failing boundary
&lt;/h2&gt;

&lt;p&gt;Use the observed result to choose the next check.&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;Next check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local path or relative URL&lt;/td&gt;
&lt;td&gt;Move the file to reachable HTTP(S) storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL works only while logged in&lt;/td&gt;
&lt;td&gt;Remove session dependency and inspect actual response bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL has expired&lt;/td&gt;
&lt;td&gt;Generate a new approved source with sufficient validity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unsupported_message_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provider capability and selected media type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;provider_not_ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Authentication and runtime status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request rejected with validation error&lt;/td&gt;
&lt;td&gt;Request schema and required fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request times out&lt;/td&gt;
&lt;td&gt;Preserve the uncertain operation; do not blindly resend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response says &lt;code&gt;accepted&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Store identifiers and wait for supported delivery evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery receipt never arrives&lt;/td&gt;
&lt;td&gt;Provider receipt support, webhook health and unknown-state policy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Branch on machine-readable error codes instead of parsing human-readable error messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test one variable at a time
&lt;/h2&gt;

&lt;p&gt;Begin with the smallest possible test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One connected account
One known conversation
One supported media type
One small file
One approved HTTPS URL
No optional provider metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid combining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a new account connection;&lt;/li&gt;
&lt;li&gt;an expiring URL;&lt;/li&gt;
&lt;li&gt;an unsupported media type;&lt;/li&gt;
&lt;li&gt;a group conversation;&lt;/li&gt;
&lt;li&gt;optional duration metadata;&lt;/li&gt;
&lt;li&gt;an automated retry system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the request fails, changing several variables at once destroys the evidence needed to identify the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suggested acceptance tests
&lt;/h2&gt;

&lt;p&gt;Before production, test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] A small reachable HTTPS image.&lt;/li&gt;
&lt;li&gt;[ ] A URL that returns an HTML login page.&lt;/li&gt;
&lt;li&gt;[ ] An expired signed URL.&lt;/li&gt;
&lt;li&gt;[ ] A redirect to an inaccessible origin.&lt;/li&gt;
&lt;li&gt;[ ] An unsupported provider/type combination.&lt;/li&gt;
&lt;li&gt;[ ] A disconnected account.&lt;/li&gt;
&lt;li&gt;[ ] A request-validation failure.&lt;/li&gt;
&lt;li&gt;[ ] An accepted request with delayed delivery evidence.&lt;/li&gt;
&lt;li&gt;[ ] A lost or timed-out client response.&lt;/li&gt;
&lt;li&gt;[ ] Duplicate webhook receipts.&lt;/li&gt;
&lt;li&gt;[ ] Out-of-order delivery events.&lt;/li&gt;
&lt;li&gt;[ ] A repeated send job after worker restart.&lt;/li&gt;
&lt;li&gt;[ ] Redaction of signed URLs and credentials in logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not only to prove the happy path. It is to prove that an uncertain result does not create duplicate customer messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  A production state machine
&lt;/h2&gt;

&lt;p&gt;A useful outbound state model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;draft
  ↓
submitting
  ├── accepted
  │      ├── delivered
  │      │      └── read
  │      ├── failed
  │      └── unknown
  ├── failed
  └── unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transitions should come from evidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transition&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;draft → submitting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worker claims the operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;submitting → accepted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Successful API response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;submitting → failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Definitive API rejection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;submitting → unknown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timeout or lost response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accepted → delivered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Supported delivery event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accepted → failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Supported failure evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delivered → read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Supported read evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This prevents the UI from presenting “delivered” immediately after request acceptance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional metadata should remain optional
&lt;/h2&gt;

&lt;p&gt;Some providers support additional media metadata.&lt;/p&gt;

&lt;p&gt;For example, WhatsApp audio and video requests may accept duration information through:&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_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;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&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 copy an inbound millisecond value directly into a field expressed in seconds.&lt;/p&gt;

&lt;p&gt;Also do not invent optional metadata when it is not required.&lt;/p&gt;

&lt;p&gt;If the provider can process the media without it, omitting uncertain metadata is safer than sending a guessed value.&lt;/p&gt;

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

&lt;p&gt;Reliable media sending requires more than valid JSON.&lt;/p&gt;

&lt;p&gt;Trace the complete path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Supported media type
        ↓
Reachable approved URL
        ↓
Valid request
        ↓
Ready account
        ↓
Accepted operation
        ↓
Provider delivery evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A local path is not a URL.
A browser session is not remote accessibility.
Accepted is not delivered.
A timeout is not a confirmed failure.
A request ID is not an idempotency key.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with one explicit media source, store the outbound operation before sending, and keep uncertain outcomes separate from retries.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/messages/send-media-message/" rel="noopener noreferrer"&gt;UnifyPort: Send media message&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/getting-started/send-media-guide/" rel="noopener noreferrer"&gt;UnifyPort: Media sending guide&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: Message support matrix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/docs/reference/error-reference/" rel="noopener noreferrer"&gt;UnifyPort: Error reference&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/blog/unifyport-request-id-error-tracing/" rel="noopener noreferrer"&gt;UnifyPort: Request tracing guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://core.telegram.org/bots/api#sending-files" rel="noopener noreferrer"&gt;Telegram Bot API: Sending files&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>webhooks</category>
      <category>debugging</category>
      <category>javascript</category>
    </item>
    <item>
      <title>WhatsApp October 2026 Pricing: Build a Cost Model Before You Migrate</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Mon, 28 Sep 2026 10:24:32 +0000</pubDate>
      <link>https://dev.to/unifyport/whatsapp-october-2026-pricing-build-a-cost-model-before-you-migrate-h6d</link>
      <guid>https://dev.to/unifyport/whatsapp-october-2026-pricing-build-a-cost-model-before-you-migrate-h6d</guid>
      <description>&lt;p&gt;WhatsApp pricing changes on October 1, 2026.&lt;/p&gt;

&lt;p&gt;For teams using the official WhatsApp Business Platform, ordinary customer-service replies will no longer always have a zero Meta message charge.&lt;/p&gt;

&lt;p&gt;The first 1,000 delivered Service messages per business phone number each month remain free. Deliveries after that allowance use the Service rate for the recipient’s market.&lt;/p&gt;

&lt;p&gt;That sounds like a simple formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Service charge
  = (messages − 1,000)
  × market rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, a useful cost model must also account for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multiple business phone numbers;&lt;/li&gt;
&lt;li&gt;recipients in several markets;&lt;/li&gt;
&lt;li&gt;message categories;&lt;/li&gt;
&lt;li&gt;free entry-point traffic;&lt;/li&gt;
&lt;li&gt;provider fees;&lt;/li&gt;
&lt;li&gt;support software;&lt;/li&gt;
&lt;li&gt;AI usage;&lt;/li&gt;
&lt;li&gt;account-based subscription alternatives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article builds that model and compares it with UnifyPort’s messaging-account subscription structure without treating the two products as equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes on October 1, 2026?
&lt;/h2&gt;

&lt;p&gt;Meta’s updated pricing documentation describes a new charge for Service messages delivered through the official WhatsApp Business Platform.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message or situation&lt;/th&gt;
&lt;th&gt;From October 1, 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Incoming customer message&lt;/td&gt;
&lt;td&gt;No Meta message charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service reply within the 24-hour window&lt;/td&gt;
&lt;td&gt;First 1,000 per business number each month are free; later deliveries use the recipient-market rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility template inside the 24-hour window&lt;/td&gt;
&lt;td&gt;Charged under Utility rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eligible 72-hour free entry-point window&lt;/td&gt;
&lt;td&gt;Message-delivery exception remains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta Business Agent usage&lt;/td&gt;
&lt;td&gt;Separate token-based pricing model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 24-hour customer-service window is still a sending restriction.&lt;/p&gt;

&lt;p&gt;It does not mean every message inside that window belongs to the same billing category.&lt;/p&gt;

&lt;p&gt;An ordinary Service reply, Utility template and eligible free-entry-point message must be classified separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The allowance belongs to each phone number
&lt;/h2&gt;

&lt;p&gt;The 1,000-message monthly allowance applies to each business phone number.&lt;/p&gt;

&lt;p&gt;It is not one shared workspace allowance.&lt;/p&gt;

&lt;p&gt;Suppose a business operates two numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Number A: 1,600 delivered Service messages
Number B: 0 delivered Service messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The billable quantity is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Number A: 1,600 − 1,000 = 600
Number B: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot transfer Number B’s unused allowance to Number A.&lt;/p&gt;

&lt;p&gt;Compare that with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Number A: 800 delivered Service messages
Number B: 800 delivered Service messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both numbers remain within their individual allowances, so the Meta Service charge is zero.&lt;/p&gt;

&lt;p&gt;The distribution of traffic matters—not only the total volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The allowance is also shared across recipient markets
&lt;/h2&gt;

&lt;p&gt;A single business number may send Service messages to customers in several markets.&lt;/p&gt;

&lt;p&gt;Do not subtract 1,000 separately from every market.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Brazil deliveries − 1,000
Germany deliveries − 1,000
United Kingdom deliveries − 1,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First 1,000 Service deliveries for the number are free
Later deliveries use the rate for each recipient’s market
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means an accurate implementation should retain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;business phone number;&lt;/li&gt;
&lt;li&gt;delivery timestamp;&lt;/li&gt;
&lt;li&gt;recipient market;&lt;/li&gt;
&lt;li&gt;delivered message category;&lt;/li&gt;
&lt;li&gt;free-entry-point status;&lt;/li&gt;
&lt;li&gt;billing eligibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Aggregating only by market can apply the allowance incorrectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  October Service rates used in this example
&lt;/h2&gt;

&lt;p&gt;The following USD rates come from the October 1, 2026 rate card cited by Meta’s pricing documentation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recipient market&lt;/th&gt;
&lt;th&gt;Service rate per delivered message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;India&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;North America&lt;/td&gt;
&lt;td&gt;$0.0034&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brazil&lt;/td&gt;
&lt;td&gt;$0.0068&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;$0.0160&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;United Kingdom&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For one phone number sending only one-to-one Service messages to one market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monthly Service charge
  = max(delivered Service messages − 1,000, 0)
  × recipient-market rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The estimated charges are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recipient market&lt;/th&gt;
&lt;th&gt;10,000 deliveries&lt;/th&gt;
&lt;th&gt;100,000 deliveries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;India&lt;/td&gt;
&lt;td&gt;$12.60&lt;/td&gt;
&lt;td&gt;$138.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;North America&lt;/td&gt;
&lt;td&gt;$30.60&lt;/td&gt;
&lt;td&gt;$336.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brazil&lt;/td&gt;
&lt;td&gt;$61.20&lt;/td&gt;
&lt;td&gt;$673.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;$144.00&lt;/td&gt;
&lt;td&gt;$1,584.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;United Kingdom&lt;/td&gt;
&lt;td&gt;$198.00&lt;/td&gt;
&lt;td&gt;$2,178.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;$495.00&lt;/td&gt;
&lt;td&gt;$5,445.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are estimates, not measured customer bills.&lt;/p&gt;

&lt;p&gt;They assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one business number;&lt;/li&gt;
&lt;li&gt;one recipient market;&lt;/li&gt;
&lt;li&gt;one-to-one Service messages;&lt;/li&gt;
&lt;li&gt;no eligible free-entry-point traffic;&lt;/li&gt;
&lt;li&gt;no other message categories;&lt;/li&gt;
&lt;li&gt;an unchanged rate for the entire month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They exclude provider fees, support software, AI, staffing, taxes and currency conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  A two-number Brazil example
&lt;/h2&gt;

&lt;p&gt;Assume a support team operates two WhatsApp numbers.&lt;/p&gt;

&lt;p&gt;Each number delivers 10,000 ordinary Service replies to customers in Brazil during one month.&lt;/p&gt;

&lt;p&gt;The free allowance is deducted separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Billable messages per number
  = 10,000 − 1,000
  = 9,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monthly charge for both numbers is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 × 9,000 × $0.0068
  = $122.40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 100,000 replies per number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 × 99,000 × $0.0068
  = $1,346.40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 800 replies per number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Both numbers remain below 1,000
Meta Service charge = $0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The low-volume row is important.&lt;/p&gt;

&lt;p&gt;If every number stays inside its allowance, avoiding Service charges alone is not a reason to migrate to another integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model delivered messages, not API requests
&lt;/h2&gt;

&lt;p&gt;A successful send request is not necessarily a delivered, billable message.&lt;/p&gt;

&lt;p&gt;Your cost pipeline should distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request accepted
Message sent
Message delivered
Message failed
Message classified
Message billed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not estimate charges by counting HTTP &lt;code&gt;200&lt;/code&gt; responses from the send endpoint.&lt;/p&gt;

&lt;p&gt;Use delivery and billing evidence from the platform or provider.&lt;/p&gt;

&lt;p&gt;A simplified record 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;delivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;businessNumberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number_br_01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;recipientMarket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;category&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;deliveredAt&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-10-12T08:31:04Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;freeEntryPoint&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;deliveryStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&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;Keep the original classification inputs so the calculation can be audited later.&lt;/p&gt;

&lt;h2&gt;
  
  
  An illustrative calculator
&lt;/h2&gt;

&lt;p&gt;For a single number, process Service deliveries in chronological order.&lt;/p&gt;

&lt;p&gt;The first 1,000 eligible deliveries consume the monthly allowance. Later deliveries use their market rates.&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;serviceRatesUsd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;IN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0014&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;NORTH_AMERICA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0034&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;BR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0068&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;SG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.016&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;GB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.022&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.055&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;calculateNumberServiceCharge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deliveries&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;eligible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deliveries&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="nx"&gt;delivery&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;delivery&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;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freeEntryPoint&lt;/span&gt; &lt;span class="o"&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;return&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveredAt&lt;/span&gt;&lt;span class="p"&gt;)&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveredAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;remainingAllowance&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalUsd&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;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;delivery&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;eligible&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;remainingAllowance&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nx"&gt;remainingAllowance&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="k"&gt;continue&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;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;serviceRatesUsd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipientMarket&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;rate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;`Missing rate for market: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipientMarket&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="nx"&gt;totalUsd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;rate&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;totalUsd&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 an explanatory example, not production billing code.&lt;/p&gt;

&lt;p&gt;A real billing system should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;avoid binary floating-point arithmetic for money;&lt;/li&gt;
&lt;li&gt;use versioned rate cards;&lt;/li&gt;
&lt;li&gt;preserve the billing timezone and period;&lt;/li&gt;
&lt;li&gt;support corrected delivery classifications;&lt;/li&gt;
&lt;li&gt;distinguish every applicable message category;&lt;/li&gt;
&lt;li&gt;reconcile estimates against actual billing exports.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Store rates as versioned data
&lt;/h2&gt;

&lt;p&gt;Do not hardcode one permanent price table into business logic.&lt;/p&gt;

&lt;p&gt;Rates can change.&lt;/p&gt;

&lt;p&gt;A versioned model could include:&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;rateCard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;effectiveFrom&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-10-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;effectiveTo&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;category&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;IN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0014&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;NORTH_AMERICA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0034&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;BR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0068&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0160&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;GB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0220&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.0550&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;When calculating October traffic, select the rate card effective on the delivery date.&lt;/p&gt;

&lt;p&gt;This also makes later audits reproducible after pricing changes again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the correct cost components
&lt;/h2&gt;

&lt;p&gt;The official API and UnifyPort use different commercial models.&lt;/p&gt;

&lt;h3&gt;
  
  
  Official WhatsApp Business Platform
&lt;/h3&gt;

&lt;p&gt;Costs may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Meta message charges;&lt;/li&gt;
&lt;li&gt;integration engineering;&lt;/li&gt;
&lt;li&gt;webhook and delivery monitoring;&lt;/li&gt;
&lt;li&gt;support inbox software;&lt;/li&gt;
&lt;li&gt;provider or BSP charges;&lt;/li&gt;
&lt;li&gt;seats;&lt;/li&gt;
&lt;li&gt;AI usage;&lt;/li&gt;
&lt;li&gt;operations;&lt;/li&gt;
&lt;li&gt;taxes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  UnifyPort
&lt;/h3&gt;

&lt;p&gt;UnifyPort uses monthly messaging-account subscriptions rather than Meta’s Service-message rate card for traffic sent through its unofficial interface.&lt;/p&gt;

&lt;p&gt;Its current graduated pricing includes one messaging account per workspace, followed by paid account bands:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Paid account positions&lt;/th&gt;
&lt;th&gt;Monthly price per account&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1–5&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6–10&lt;/td&gt;
&lt;td&gt;$8.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11–200&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;201–1,000&lt;/td&gt;
&lt;td&gt;$4.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,001 onward&lt;/td&gt;
&lt;td&gt;$4.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pricing is graduated.&lt;/p&gt;

&lt;p&gt;For ten paid accounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First 5 × $10 = $50
Next 5 × $8 = $40
Total = $90/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reaching the second band does not reprice all ten accounts at $8.&lt;/p&gt;

&lt;p&gt;Together with the included account, ten paid accounts provide eleven messaging accounts in the workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the comparison is not apples to apples
&lt;/h2&gt;

&lt;p&gt;Meta Service charges and UnifyPort subscriptions represent different line items.&lt;/p&gt;

&lt;p&gt;The official example measures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Delivered Service messages
× recipient-market rates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UnifyPort example measures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connected paid messaging accounts
× graduated subscription prices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They do not provide identical authorization, capabilities or guarantees.&lt;/p&gt;

&lt;p&gt;For the two-number Brazil example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Meta Service estimate:
$122.40/month
&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;UnifyPort account subscription estimate:
1 included account
+ 1 paid account × $10
= $10/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between those listed charges is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$122.40 − $10
= $112.40/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not proof of a $112.40 reduction in total operating cost.&lt;/p&gt;

&lt;p&gt;The calculation excludes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;implementation;&lt;/li&gt;
&lt;li&gt;monitoring;&lt;/li&gt;
&lt;li&gt;staff;&lt;/li&gt;
&lt;li&gt;support software;&lt;/li&gt;
&lt;li&gt;AI;&lt;/li&gt;
&lt;li&gt;taxes;&lt;/li&gt;
&lt;li&gt;throughput requirements;&lt;/li&gt;
&lt;li&gt;failure handling;&lt;/li&gt;
&lt;li&gt;account continuity;&lt;/li&gt;
&lt;li&gt;provider-specific capabilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the difference as one input to a broader architecture decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capability differences matter
&lt;/h2&gt;

&lt;p&gt;Evaluate the official path when your product depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;official templates;&lt;/li&gt;
&lt;li&gt;Meta-native workflows;&lt;/li&gt;
&lt;li&gt;official authorization;&lt;/li&gt;
&lt;li&gt;formal Business Platform capabilities;&lt;/li&gt;
&lt;li&gt;provider or BSP support;&lt;/li&gt;
&lt;li&gt;contractual requirements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Evaluate an account-based interface when your priority is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connecting an existing operational account;&lt;/li&gt;
&lt;li&gt;ordinary-account messaging;&lt;/li&gt;
&lt;li&gt;multi-platform intake;&lt;/li&gt;
&lt;li&gt;one API and webhook model across channels;&lt;/li&gt;
&lt;li&gt;account-based subscription pricing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An unofficial interface is not a drop-in equivalent to the official Cloud API.&lt;/p&gt;

&lt;p&gt;Include access changes, account restrictions, reauthorization and continuity in any production trial.&lt;/p&gt;

&lt;p&gt;A lower estimated message-related charge is not useful if the alternative cannot support a required workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Low-volume and high-volume teams reach different conclusions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Low-volume example
&lt;/h3&gt;

&lt;p&gt;Two numbers each send 800 Service replies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Meta Service charge: $0
UnifyPort subscription estimate: $10/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official message charge is lower in this narrow comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  Higher-volume example
&lt;/h3&gt;

&lt;p&gt;Two Brazil numbers each send 10,000 Service replies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Meta Service charge: $122.40
UnifyPort subscription estimate: $10/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account subscription is lower than the selected Meta line item, but capability and operational differences still need evaluation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Distribution example
&lt;/h3&gt;

&lt;p&gt;One number sends 1,600 replies and the second sends none:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Billable messages: 600
Charge: 600 × $0.0068 = $4.08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the same 1,600 replies are evenly split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Number A: 800
Number B: 800
Charge: $0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account topology changes the result even when total traffic is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI creates another cost layer
&lt;/h2&gt;

&lt;p&gt;An AI support workflow can include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model or agent usage
+ message-delivery charges
+ orchestration
+ retrieval
+ human escalation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not assume that changing the messaging integration makes third-party AI free.&lt;/p&gt;

&lt;p&gt;For a useful AI cost model, measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model tokens per conversation;&lt;/li&gt;
&lt;li&gt;replies required to resolve an issue;&lt;/li&gt;
&lt;li&gt;tool calls;&lt;/li&gt;
&lt;li&gt;retrieval cost;&lt;/li&gt;
&lt;li&gt;human handoff rate;&lt;/li&gt;
&lt;li&gt;message-delivery category;&lt;/li&gt;
&lt;li&gt;delivery success rate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optimize for resolved customer issues, not merely the cost of one generated reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a pre-October worksheet
&lt;/h2&gt;

&lt;p&gt;Export one representative month and group records by:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Business phone number
Recipient market
Delivery timestamp
Delivered message category
Free-entry-point eligibility
Delivery status
Provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then calculate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The 1,000-message allowance independently for each number.&lt;/li&gt;
&lt;li&gt;Recipient-market rates after each number exhausts its allowance.&lt;/li&gt;
&lt;li&gt;Utility and other categories separately.&lt;/li&gt;
&lt;li&gt;Free-entry-point exceptions separately.&lt;/li&gt;
&lt;li&gt;Provider, software, AI and operational fees.&lt;/li&gt;
&lt;li&gt;Account-subscription alternatives by paid account position.&lt;/li&gt;
&lt;li&gt;Capability gaps and migration costs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A final comparison should resemble:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost or requirement&lt;/th&gt;
&lt;th&gt;Official API&lt;/th&gt;
&lt;th&gt;Account-based alternative&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service message charges&lt;/td&gt;
&lt;td&gt;Calculated from delivered traffic&lt;/td&gt;
&lt;td&gt;Not the same billing model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging account subscription&lt;/td&gt;
&lt;td&gt;Provider-specific&lt;/td&gt;
&lt;td&gt;Graduated account pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support inbox&lt;/td&gt;
&lt;td&gt;Add if required&lt;/td&gt;
&lt;td&gt;Add if required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Add if required&lt;/td&gt;
&lt;td&gt;Add if required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering&lt;/td&gt;
&lt;td&gt;Estimate&lt;/td&gt;
&lt;td&gt;Estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Estimate&lt;/td&gt;
&lt;td&gt;Estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official templates&lt;/td&gt;
&lt;td&gt;Supported according to official product&lt;/td&gt;
&lt;td&gt;Verify before considering migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account continuity risk&lt;/td&gt;
&lt;td&gt;Evaluate&lt;/td&gt;
&lt;td&gt;Evaluate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-channel normalization&lt;/td&gt;
&lt;td&gt;Build or buy&lt;/td&gt;
&lt;td&gt;Evaluate available provider support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The October 2026 WhatsApp pricing change should not trigger an automatic migration.&lt;/p&gt;

&lt;p&gt;It should trigger a better cost model.&lt;/p&gt;

&lt;p&gt;Calculate using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Delivered messages
Business phone number
Recipient market
Message category
Free-entry-point status
Current rate card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare the complete operating model—not one message charge against one subscription fee.&lt;/p&gt;

&lt;p&gt;For low-volume numbers, the official Service charge may remain zero.&lt;/p&gt;

&lt;p&gt;For higher-volume numbers, recipient-market rates can become material.&lt;/p&gt;

&lt;p&gt;In every case, capability, authorization, reliability and operational ownership belong in the same decision as price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/non-template-messages?locale=en_US" rel="noopener noreferrer"&gt;Meta non-template pricing announcement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing?locale=en_US" rel="noopener noreferrer"&gt;Meta WhatsApp pricing documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing?locale=en_US#rate-cards-effective-october-1-2026" rel="noopener noreferrer"&gt;Meta October 1, 2026 rate cards&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/pricing/" rel="noopener noreferrer"&gt;UnifyPort pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/legal/terms/" rel="noopener noreferrer"&gt;UnifyPort terms of service&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pricing figures were checked on September 28, 2026. Recheck the official documentation and your applicable agreement before making a production or purchasing decision.&lt;/p&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>api</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Telegram Inline Button Keeps Loading? Fix answerCallbackQuery Correctly</title>
      <dc:creator>unifyport</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:48:12 +0000</pubDate>
      <link>https://dev.to/unifyport/telegram-inline-button-keeps-loading-fix-answercallbackquery-correctly-haf</link>
      <guid>https://dev.to/unifyport/telegram-inline-button-keeps-loading-fix-answercallbackquery-correctly-haf</guid>
      <description>&lt;p&gt;A user presses an inline button in your Telegram bot.&lt;/p&gt;

&lt;p&gt;The callback reaches your webhook. Your server returns HTTP &lt;code&gt;200&lt;/code&gt;. The business action may even complete successfully.&lt;/p&gt;

&lt;p&gt;But the button keeps showing a loading indicator.&lt;/p&gt;

&lt;p&gt;The missing step is usually:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Telegram treats these as three separate outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;How it is confirmed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Telegram delivered the update&lt;/td&gt;
&lt;td&gt;Your webhook returns a successful HTTP response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your bot acknowledged the button press&lt;/td&gt;
&lt;td&gt;Your bot calls &lt;code&gt;answerCallbackQuery&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The requested business operation completed&lt;/td&gt;
&lt;td&gt;Your application commits and records the result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Returning HTTP &lt;code&gt;200&lt;/code&gt; acknowledges webhook delivery. It does not answer the callback query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callback buttons are not text messages
&lt;/h2&gt;

&lt;p&gt;Consider this inline keyboard:&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;"inline_keyboard"&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;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;"Approve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"callback_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;"approval:approve:request_42"&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;"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;"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;"callback_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;"approval:reject:request_42"&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;When the user presses one of these buttons, Telegram does not send an ordinary text message.&lt;/p&gt;

&lt;p&gt;It sends an update containing &lt;code&gt;callback_query&lt;/code&gt;:&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;"update_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;710000001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"callback_query"&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;"4382bfdwdsb323b2d9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"from"&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;false&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;"Jordan"&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;"message_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;815&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"chat"&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;-1001234567890&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;"supergroup"&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;"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;"Approve request #42?"&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;"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;"approval:approve:request_42"&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;A handler that only checks &lt;code&gt;update.message&lt;/code&gt; will ignore this interaction:&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="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;Instead, dispatch callbacks explicitly:&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;callback_query&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;handleCallbackQuery&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;callback_query&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;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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleMessage&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Use the correct identifier
&lt;/h2&gt;

&lt;p&gt;The callback answer requires:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Its value comes from:&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;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callback_query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;update_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;chat.id&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inline_message_id&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;callback_query.data&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The correct mapping is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Incoming field&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;callback_query.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pass as &lt;code&gt;callback_query_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;callback_query.data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Application input associated with the button&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;callback_query.from&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User who pressed the button&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;callback_query.message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Message context, when present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;callback_query.inline_message_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inline-mode message context, when present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A wrong ID can leave the client waiting even though your webhook completed successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call &lt;code&gt;answerCallbackQuery&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The Bot API request looks like this:&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;/answerCallbackQuery"&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;'{
    "callback_query_id": "4382bfdwdsb323b2d9"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;text&lt;/code&gt; field is optional.&lt;/p&gt;

&lt;p&gt;You do not need to show a notification merely to clear the progress indicator.&lt;/p&gt;

&lt;p&gt;A small JavaScript helper can make 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;callbackQueryId&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="nx"&gt;showAlert&lt;/span&gt; &lt;span class="o"&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="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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;callback_query_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;callbackQueryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;show_alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;showAlert&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;text&lt;/span&gt;&lt;span class="p"&gt;)&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;text&lt;/span&gt; &lt;span class="o"&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="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;/answerCallbackQuery`&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;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;payload&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;result&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;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="o"&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;ok&lt;/span&gt; &lt;span class="o"&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="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;`answerCallbackQuery 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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&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;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;Keep &lt;code&gt;BOT_TOKEN&lt;/code&gt; on the server. Never include it in browser code, source control, logs, screenshots, or support tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer promptly
&lt;/h2&gt;

&lt;p&gt;Do not wait for a slow business operation before answering the interaction.&lt;/p&gt;

&lt;p&gt;This is fragile:&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;handleCallbackQuery&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callCrm&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="nx"&gt;data&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;runAiClassification&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="nx"&gt;data&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;updateDatabase&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="nx"&gt;data&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;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;callbackQueryId&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="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;If the CRM or AI request is slow, the Telegram client continues displaying progress while the user waits.&lt;/p&gt;

&lt;p&gt;Answer the interaction first, then perform the work:&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;handleCallbackQuery&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;callbackQueryId&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="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueCallbackAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;callbackQueryId&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;userId&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="k"&gt;from&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;data&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="nx"&gt;data&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;query&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;message_id&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;chatId&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="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="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;inlineMessageId&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="nx"&gt;inline_message_id&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="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 acknowledges the button press without pretending the underlying operation already succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Button feedback is not business success
&lt;/h2&gt;

&lt;p&gt;Suppose the button approves a refund.&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;answerCallbackQuery&lt;/code&gt; means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The bot acknowledged the button press.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The refund was approved and committed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep interaction state and business state separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Callback received
    ↓
Callback answered
    ↓
Request validated
    ↓
Authorization checked
    ↓
Business transition committed
    ↓
Message updated with the final result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user-facing flow might be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clear the Telegram loading indicator immediately.&lt;/li&gt;
&lt;li&gt;Validate the callback payload.&lt;/li&gt;
&lt;li&gt;Confirm that the user is authorized.&lt;/li&gt;
&lt;li&gt;Perform the state transition idempotently.&lt;/li&gt;
&lt;li&gt;Edit the message or send a new message with the real outcome.&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;processApprovalAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&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="nf"&gt;parseCallbackData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertAuthorized&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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="nx"&gt;action&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;resourceId&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;resourceId&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;result&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;applyActionIdempotently&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="nx"&gt;action&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;resourceId&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;resourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`telegram:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callbackQueryId&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="nf"&gt;publishFinalResult&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chatId&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not display “Approved” merely because the callback was received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat &lt;code&gt;callback_data&lt;/code&gt; as untrusted input
&lt;/h2&gt;

&lt;p&gt;A callback came from Telegram, but its contents should still be validated against current server-side state.&lt;/p&gt;

&lt;p&gt;Do not write logic 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="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;resourceId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="nf"&gt;split&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="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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resourceId&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate the format and allowlist the action:&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;parseCallbackData&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="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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&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;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;Missing callback data&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;namespace&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;resourceId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approval&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;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;Unsupported callback namespace&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="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;Unsupported callback action&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="sr"&gt;/^request_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]&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;resourceId&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;Invalid resource identifier&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="na"&gt;type&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;resourceId&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;Then check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether the user may perform the action;&lt;/li&gt;
&lt;li&gt;whether the object still exists;&lt;/li&gt;
&lt;li&gt;whether it is still in an actionable state;&lt;/li&gt;
&lt;li&gt;whether the action was already completed;&lt;/li&gt;
&lt;li&gt;whether the button belongs to the expected bot workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The originating message may be old, edited, forwarded, or no longer representative of the current server-side state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not require &lt;code&gt;callback_query.message&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A callback query does not always have ordinary message context.&lt;/p&gt;

&lt;p&gt;Depending on how the inline button was created, the callback may provide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;callback_query.message
&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;callback_query.inline_message_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code can crash before answering the callback:&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;chatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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;message_id&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;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callbackQueryId&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="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;Use optional context:&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;context&lt;/span&gt; &lt;span class="o"&gt;=&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;query&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="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;messageId&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="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="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;inlineMessageId&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="nx"&gt;inline_message_id&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="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callbackQueryId&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="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;Answering the callback only requires the callback query ID. Do not let missing message context block that acknowledgment.&lt;/p&gt;

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

&lt;p&gt;If the callback never reaches your handler, inspect the receiver configuration.&lt;/p&gt;

&lt;p&gt;Telegram’s &lt;code&gt;allowed_updates&lt;/code&gt; parameter must permit:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;For example, a webhook configuration 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;"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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"callback_query"&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;A configuration that only permits &lt;code&gt;message&lt;/code&gt; can deliver ordinary messages while excluding callback queries.&lt;/p&gt;

&lt;p&gt;This creates a misleading symptom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bot receives messages correctly
Inline buttons keep loading
No callback handler logs appear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiving transport may be healthy while the update filter excludes the interaction type.&lt;/p&gt;

&lt;p&gt;Telegram documents that omitting &lt;code&gt;allowed_updates&lt;/code&gt; can retain the previous setting. Do not assume leaving it out resets the receiver to every update type.&lt;/p&gt;

&lt;p&gt;Review the effective configuration used by the active webhook or polling worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate missing delivery from missing handling
&lt;/h2&gt;

&lt;p&gt;Use this diagnostic sequence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 1: no callback update reaches the receiver
&lt;/h3&gt;

&lt;p&gt;Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;allowed_updates&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;webhook delivery status;&lt;/li&gt;
&lt;li&gt;the currently configured webhook URL;&lt;/li&gt;
&lt;li&gt;whether the polling worker requests callback updates;&lt;/li&gt;
&lt;li&gt;whether the button actually uses &lt;code&gt;callback_data&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A URL button does not produce a callback query. It opens its configured URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2: the callback arrives but the loading indicator remains
&lt;/h3&gt;

&lt;p&gt;Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether &lt;code&gt;answerCallbackQuery&lt;/code&gt; was called;&lt;/li&gt;
&lt;li&gt;whether &lt;code&gt;callback_query.id&lt;/code&gt; was used;&lt;/li&gt;
&lt;li&gt;whether the Bot API response returned success;&lt;/li&gt;
&lt;li&gt;whether the handler crashed before the answer call;&lt;/li&gt;
&lt;li&gt;whether a slow dependency blocked the call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Case 3: the loading indicator clears but nothing happens
&lt;/h3&gt;

&lt;p&gt;Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;callback-data parsing;&lt;/li&gt;
&lt;li&gt;user authorization;&lt;/li&gt;
&lt;li&gt;current server-side state;&lt;/li&gt;
&lt;li&gt;queue processing;&lt;/li&gt;
&lt;li&gt;failed business transitions;&lt;/li&gt;
&lt;li&gt;final message updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Case 4: the action happens more than once
&lt;/h3&gt;

&lt;p&gt;Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;duplicate update delivery;&lt;/li&gt;
&lt;li&gt;repeated user clicks;&lt;/li&gt;
&lt;li&gt;queue retries;&lt;/li&gt;
&lt;li&gt;missing business idempotency;&lt;/li&gt;
&lt;li&gt;multiple consumers processing the same job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are different failure boundaries and should not be fixed with the same retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhook HTTP &lt;code&gt;200&lt;/code&gt; is not enough
&lt;/h2&gt;

&lt;p&gt;This webhook handler is incomplete:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveUpdate&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;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;It tells Telegram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The update was delivered successfully.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not tell the Telegram client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The button interaction was acknowledged.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A more complete structure is:&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="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;saveUpdateIfAbsent&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;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="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;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;callback_query&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;handleCallbackQuery&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;callback_query&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;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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleMessage&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="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 production, ensure errors after the HTTP response are captured by your worker or job system rather than becoming unobserved promise rejections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplicate delivery and business actions separately
&lt;/h2&gt;

&lt;p&gt;Telegram update retries and repeated button presses are different duplication problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delivery-level idempotency
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;update_id&lt;/code&gt; to prevent processing the same update twice:&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;saveUpdateIfAbsent&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="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;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Interaction-level tracking
&lt;/h3&gt;

&lt;p&gt;Record the callback query:&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="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callbackQueries&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;callbackQueryId&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userId&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="k"&gt;from&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;data&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="nx"&gt;data&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Business-level idempotency
&lt;/h3&gt;

&lt;p&gt;Protect the irreversible operation independently:&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;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`approval:&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;resourceId&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;action&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="s2"&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 two different callback queries from approving the same request twice.&lt;/p&gt;

&lt;p&gt;A unique update does not necessarily represent a unique business action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what feedback the user should see
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;answerCallbackQuery&lt;/code&gt; supports optional text.&lt;/p&gt;

&lt;p&gt;An empty answer is appropriate when the message will immediately update:&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;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callbackQueryId&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="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;Short feedback can confirm receipt:&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;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callbackQueryId&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An alert may be appropriate for an important validation error:&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;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callbackQueryId&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are not allowed to approve this request.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;showAlert&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;Do not use callback notifications as the permanent record of a business result. Update the conversation with a durable final state.&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;Request approved by Jordan at 14:32 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A production-oriented handler
&lt;/h2&gt;

&lt;p&gt;The complete flow can be organized 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleCallbackQuery&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callbackQueryId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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;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;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;callbackQueryId&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;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="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;callback_answer_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;span class="nx"&gt;callbackQueryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&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;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;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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&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="nf"&gt;parseCallbackData&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="nx"&gt;data&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="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_callback_data&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="nx"&gt;callbackQueryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;userId&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="k"&gt;from&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="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;enqueueCallbackAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;callbackQueryId&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;userId&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="k"&gt;from&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;chatId&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="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="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;messageId&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="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="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;inlineMessageId&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="nx"&gt;inline_message_id&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="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 handler deliberately separates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Telegram interaction acknowledgment.&lt;/li&gt;
&lt;li&gt;Input parsing.&lt;/li&gt;
&lt;li&gt;Durable business processing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Acceptance tests
&lt;/h2&gt;

&lt;p&gt;Before release, test all of these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] A valid callback clears the progress indicator.&lt;/li&gt;
&lt;li&gt;[ ] An empty callback answer works without notification text.&lt;/li&gt;
&lt;li&gt;[ ] Optional text displays when requested.&lt;/li&gt;
&lt;li&gt;[ ] A slow CRM or AI task does not delay the callback answer.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;callback_query.id&lt;/code&gt; is passed as &lt;code&gt;callback_query_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] A callback without &lt;code&gt;message&lt;/code&gt; does not crash the handler.&lt;/li&gt;
&lt;li&gt;[ ] Inline-message context is handled when present.&lt;/li&gt;
&lt;li&gt;[ ] Unknown callback data does not execute an action.&lt;/li&gt;
&lt;li&gt;[ ] Unauthorized users cannot perform protected actions.&lt;/li&gt;
&lt;li&gt;[ ] A stale button cannot overwrite current server state.&lt;/li&gt;
&lt;li&gt;[ ] Repeated update delivery does not duplicate processing.&lt;/li&gt;
&lt;li&gt;[ ] Repeated clicks do not repeat an irreversible operation.&lt;/li&gt;
&lt;li&gt;[ ] Final business success or failure is shown separately.&lt;/li&gt;
&lt;li&gt;[ ] Bot tokens are absent from logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bot callbacks and account-level webhooks are different
&lt;/h2&gt;

&lt;p&gt;Telegram inline keyboards and &lt;code&gt;answerCallbackQuery&lt;/code&gt; belong to the official Telegram Bot API.&lt;/p&gt;

&lt;p&gt;An account-level normalized webhook solves a different problem.&lt;/p&gt;

&lt;p&gt;UnifyPort’s documented event contract includes events 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;message.received
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not document a Telegram &lt;code&gt;callback_query&lt;/code&gt; event or an &lt;code&gt;answerCallbackQuery&lt;/code&gt; operation.&lt;/p&gt;

&lt;p&gt;Do not rename a normalized message event into a callback query, and do not assume that returning Telegram Bot API JSON from an unrelated webhook receiver will execute the bot method.&lt;/p&gt;

&lt;p&gt;If your system needs both architectures, keep them separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram Bot API
    → Inline keyboard callbacks
    → answerCallbackQuery
    → Bot-specific business logic

Connected account webhook
    → message.received
    → Multi-channel support intake
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Share downstream services where appropriate, but preserve the different authentication, event and response contracts.&lt;/p&gt;

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

&lt;p&gt;When a Telegram inline button keeps loading, trace one click through these boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Button uses callback_data
        ↓
callback_query reaches the receiver
        ↓
Handler reads callback_query.id
        ↓
Bot calls answerCallbackQuery
        ↓
Telegram returns success
        ↓
Business action runs separately
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP 200 acknowledges webhook delivery.
answerCallbackQuery acknowledges the button press.
Your database confirms the business result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping those outcomes separate produces faster interactions, safer retries and much clearer debugging.&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" rel="noopener noreferrer"&gt;Telegram Bot API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/blog/telegram-webhook-response-sendmessage-vs-api-request/" rel="noopener noreferrer"&gt;Telegram webhook response body vs separate API request&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.unifyport.ai/blog/telegram-getwebhookinfo-pending-updates-diagnostics/" rel="noopener noreferrer"&gt;Telegram getWebhookInfo delivery diagnostics&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 contract&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>javascript</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <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>
  </channel>
</rss>
