{"id":2970,"date":"2026-09-18T12:44:08","date_gmt":"2026-09-18T07:14:08","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2970"},"modified":"2026-09-18T12:44:11","modified_gmt":"2026-09-18T07:14:11","slug":"receiving-messages-four-channel-inbox-contracts","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/","title":{"rendered":"Receiving Messages on Four Channels: Inbox API Contracts Compared"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">SMS, WhatsApp, RCS and Telegram each deliver inbound messages differently. One pushes and cannot be read. Three can be read and never push. Here is every field, every type and every trap, compared side by side.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/receiving-messages-four-channel-inbox-contracts.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/receiving-messages-four-channel-inbox-contracts-1024x584.webp\" alt=\"Illustration contrasting a single one way push arrow with three looping polling cycles, representing inbound message transport across four messaging channels.\" class=\"wp-image-2971\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/receiving-messages-four-channel-inbox-contracts-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/receiving-messages-four-channel-inbox-contracts-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/receiving-messages-four-channel-inbox-contracts-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/receiving-messages-four-channel-inbox-contracts.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Inbound messages arrive by push on one channel and by polling on three. The design that survives all four accommodates both.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#four-surfaces\">The Four Inbound Surfaces at a Glance<\/a><\/li>\n\n\n\n<li><a href=\"#why-sms-differs\">Why SMS Is the Odd One Out<\/a><\/li>\n\n\n\n<li><a href=\"#sms-push-contract\">The SMS Inbound Push Contract<\/a><\/li>\n\n\n\n<li><a href=\"#receiving-sms-safely\">Receiving an SMS Reply Safely<\/a><\/li>\n\n\n\n<li><a href=\"#whatsapp-inbox\">The WhatsApp Inbox<\/a><\/li>\n\n\n\n<li><a href=\"#rcs-inbox\">The RCS Inbox<\/a><\/li>\n\n\n\n<li><a href=\"#telegram-inbox\">The Telegram Inbox<\/a><\/li>\n\n\n\n<li><a href=\"#row-comparison\">The Inbound Row, Field by Field<\/a><\/li>\n\n\n\n<li><a href=\"#identifier-namespaces\">Four Identifier Namespaces, None of Them Shared<\/a><\/li>\n\n\n\n<li><a href=\"#threading\">Threading a Reply to the Message That Caused It<\/a><\/li>\n\n\n\n<li><a href=\"#message-bodies\">Message Bodies and How They Are Encoded<\/a><\/li>\n\n\n\n<li><a href=\"#read-state\">Read State You Can Read but Cannot Write<\/a><\/li>\n\n\n\n<li><a href=\"#inbound-billing\">Inbound Billing and Agent Attribution<\/a><\/li>\n\n\n\n<li><a href=\"#response-envelopes\">Response Envelopes and the Error Shape<\/a><\/li>\n\n\n\n<li><a href=\"#inbound-table\">Designing One Inbound Table for Four Channels<\/a><\/li>\n\n\n\n<li><a href=\"#polling-loop\">The Polling Loop That Does Not Lose Messages<\/a><\/li>\n\n\n\n<li><a href=\"#language-samples\">Four Language Samples, Four Different Traps<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes That Cost People Messages<\/a><\/li>\n\n\n\n<li><a href=\"#production-checklist\">Production Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There are four inbound message surfaces on SMSGatewayCenter and they split two against two on transport. SMS delivers inbound messages by pushing an HTTP GET to a URL you supply, carrying six query parameters, and there is no endpoint anywhere that lets you read an SMS reply back. WhatsApp, RCS and Telegram each expose a read-only REST inbox at <code>rest\/wa\/v1\/inbox<\/code>, <code>rest\/rcs\/v1\/inbox<\/code> and <code>rest\/tg\/v1\/inbox<\/code>, all GET, all <code>action=list<\/code>, all paged on <code>page<\/code> and <code>limit<\/code> with a default of 50 and a maximum of 200, newest first, and none of the three pushes anything to you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That split is the exact inverse of the delivery report split. SMS is the only channel with an outbound delivery push, and the account holds exactly one webhook slot for it. All four channels expose a delivery report poll. So SMS pushes what it will not serve and serves what it will not push, and the other three do the opposite.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical consequence is that you cannot write one inbound handler. You need a public HTTP endpoint that is idempotent against a payload carrying no identifier and no timestamp, and a scheduled poller that walks three paged REST inboxes on a cursor you persist yourself. Getting either half wrong loses customer replies silently, which is the worst failure mode a two-way integration has, because nothing errors and nobody complains until a sales lead goes cold.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>SMS is push only.<\/strong> The platform issues an HTTP GET to your URL with <code>phonecode<\/code>, <code>keyword<\/code>, <code>phoneno<\/code>, <code>content<\/code>, <code>location<\/code> and <code>carrier<\/code>. There is no SMS inbox read endpoint in the developer API.<\/li>\n\n\n\n<li><strong>WhatsApp, RCS and Telegram are poll only.<\/strong> Read-only REST inboxes, <code>action=list<\/code>, <code>page<\/code> and <code>limit<\/code>, default 50, maximum 200, newest first.<\/li>\n\n\n\n<li><strong>The SMS push carries no identifier and no timestamp.<\/strong> The three REST inboxes all carry an <code>incomingId<\/code> and an epoch millisecond timestamp. On SMS you synthesise both or you cannot deduplicate.<\/li>\n\n\n\n<li><strong>The message body is encoded three different ways.<\/strong> Plain text on WhatsApp and Telegram, a double-encoded JSON string on RCS, and a URL-encoded query parameter on SMS.<\/li>\n\n\n\n<li><strong>WhatsApp is the only channel that bills you to receive.<\/strong> Inbox rows carry <code>charges<\/code>. No other channel publishes a per-message inbound cost field.<\/li>\n\n\n\n<li><strong>WhatsApp is the only channel with agent attribution.<\/strong> <code>agentId<\/code> and <code>agentName<\/code>, and <code>agentName<\/code> uses the literal string <code>\"NA\"<\/code> as a null sentinel, so a naive null check passes and you store the word NA.<\/li>\n\n\n\n<li><strong>Read state is readable and not writable.<\/strong> Telegram publishes <code>isRead<\/code>, WhatsApp publishes <code>isReplied<\/code>, RCS publishes neither, and no endpoint on any channel sets either flag.<\/li>\n\n\n\n<li><strong>Four inbound identifier namespaces, none of which links back to an outbound send.<\/strong> Threading a reply to the message that prompted it is always a correlation problem you solve in your own data, never a join the platform gives you.<\/li>\n\n\n\n<li><strong>The error payload flips type.<\/strong> WhatsApp returns <code>\"inboxList\": []<\/code> on error. Strongly typed clients that bind the success shape throw a parse error and hide the real reason.<\/li>\n\n\n\n<li><strong>Send the <code>apikey<\/code> header on every one of these calls.<\/strong> It works on every endpoint of every API on the platform, and it keeps credentials out of query strings and out of your access logs.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-surfaces\" class=\"wp-block-heading\">The Four Inbound Surfaces at a Glance<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-inbound-transport-split.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-inbound-transport-split.svg\" alt=\"Diagram showing SMS receiving inbound messages by HTTP GET push with six query parameters and no read endpoint, against WhatsApp, RCS and Telegram each exposing a read-only REST inbox with a shared response envelope, plus a note that the delivery report direction is inverted.\" class=\"wp-image-2972\"\/><\/a><figcaption class=\"wp-element-caption\">SMS pushes inbound and cannot be read back. The other three can be read and never push. The delivery report direction runs the other way.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><\/th><th>SMS<\/th><th>WhatsApp<\/th><th>RCS<\/th><th>Telegram<\/th><\/tr><\/thead><tbody><tr><td>Transport<\/td><td>Push, HTTP GET to your URL<\/td><td>Poll<\/td><td>Poll<\/td><td>Poll<\/td><\/tr><tr><td>Read endpoint<\/td><td>None<\/td><td><code>rest\/wa\/v1\/inbox<\/code><\/td><td><code>rest\/rcs\/v1\/inbox<\/code><\/td><td><code>rest\/tg\/v1\/inbox<\/code><\/td><\/tr><tr><td>HTTP method<\/td><td>You receive GET<\/td><td>GET<\/td><td>GET<\/td><td>GET<\/td><\/tr><tr><td>Format parameter<\/td><td>Not applicable<\/td><td><code>output<\/code><\/td><td><code>output<\/code><\/td><td><code>output<\/code><\/td><\/tr><tr><td>Action parameter<\/td><td>Not applicable<\/td><td><code>action=list<\/code><\/td><td><code>action=list<\/code><\/td><td><code>action=list<\/code><\/td><\/tr><tr><td>Paging<\/td><td>None<\/td><td><code>page<\/code>, <code>limit<\/code>, max 200<\/td><td><code>page<\/code>, <code>limit<\/code>, max 200<\/td><td><code>page<\/code>, <code>limit<\/code>, max 200<\/td><\/tr><tr><td>Order<\/td><td>Arrival order, as pushed<\/td><td>Newest first<\/td><td>Newest first<\/td><td>Newest first<\/td><\/tr><tr><td>Replayable<\/td><td>No<\/td><td>Yes<\/td><td>Yes<\/td><td>Yes<\/td><\/tr><tr><td>Real time<\/td><td>Yes<\/td><td>No<\/td><td>No<\/td><td>No<\/td><\/tr><tr><td>Addressing unit<\/td><td>Long code plus keyword<\/td><td><code>wabaNumber<\/code><\/td><td><code>botId<\/code><\/td><td>One bot per account<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Read the last four rows as the real design constraint. SMS is real time and not replayable. The other three are replayable and not real time. If your product promises a customer service agent sees a reply within seconds, SMS gives you that for free and the other three cost you a polling interval. If your product promises no reply is ever lost, the other three give you that for free and SMS costs you an idempotent, highly available endpoint.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-sms-differs\" class=\"wp-block-heading\">Why SMS Is the Odd One Out<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Inbound SMS on this platform is a long code or short code product rather than an API product. You buy a number, you register keywords against it, and the platform forwards every message that arrives at that number to a URL you nominate. The <a href=\"https:\/\/www.smsgatewaycenter.com\/long-code-sms-services\/\">long code SMS service page<\/a> describes the commercial shape, and the dedicated plan features state the mechanism in one line: instantly forward incoming SMS data to your preferred application or CRM using secure API or webhook integration for real-time processing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two consequences follow from inbound SMS being a number product rather than an API product, and both of them shape your code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first is that the unit of subscription is a number and a keyword, not an account. On WhatsApp the inbox is scoped by <code>wabaNumber<\/code>, on RCS by <code>botId<\/code>, and on Telegram there is exactly one bot per account so there is nothing to scope by at all. On SMS the equivalent scoping arrives in the payload itself, as <code>phonecode<\/code> and <code>keyword<\/code>, and it arrives on every single message. If you run several campaigns against one shared long code, the keyword is the only thing distinguishing them, and it is marked optional in the documented handler, so you must treat its absence as a real case rather than an impossible one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second is that there is no historical record you can query. Every other channel on the platform lets you go back and re-read what arrived. Inbound SMS gives you exactly one delivery attempt to a URL that might have been down. That single fact should drive your endpoint design more than anything else, and the next two sections are about nothing else.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"sms-push-contract\" class=\"wp-block-heading\">The SMS Inbound Push Contract<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The platform issues an HTTP GET to the URL you supply. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/php-script-retrieve-shortcode-incoming-sms-data\/\">documented PHP handler<\/a> reads six parameters, and the same article publishes a complete sample URL that shows exactly what arrives on the wire:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>https:&#47;&#47;mysite.com\/incoming_sms.php?phonecode=12345&amp;keyword=SGCSMS&amp;phoneno=9930447726&amp;content=please+call+back+as+am+interested+in+your+services&amp;location=Mumbai&amp;carrier=Idea<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Parameter<\/th><th>Meaning<\/th><th>Documented as<\/th><\/tr><\/thead><tbody><tr><td><code>phonecode<\/code><\/td><td>The short code or long code number the message arrived at<\/td><td>Optional<\/td><\/tr><tr><td><code>keyword<\/code><\/td><td>The keyword the sender used, such as <code>SGCSMS<\/code><\/td><td>Optional<\/td><\/tr><tr><td><code>phoneno<\/code><\/td><td>The sender&#8217;s mobile number<\/td><td>Always present<\/td><\/tr><tr><td><code>content<\/code><\/td><td>The message text, URL encoded<\/td><td>Always present<\/td><\/tr><tr><td><code>location<\/code><\/td><td>The sender&#8217;s telecom circle, such as <code>Mumbai<\/code><\/td><td>Optional<\/td><\/tr><tr><td><code>carrier<\/code><\/td><td>The sender&#8217;s operator, such as <code>Idea<\/code><\/td><td>Optional<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Four of the six are optional. <code>phoneno<\/code> and <code>content<\/code> are the two the sample handler treats as load bearing, and they are the two your code must work without guessing about. The sample handler also notes that if <code>$_GET<\/code> does not populate, <code>$_REQUEST<\/code> should be tried, which is a useful hint that you should not hard-bind to the GET method in your framework routing. Accept both GET and POST on the endpoint and read the parameters from whichever collection carries them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three things are conspicuously not in that list, and each one changes how you write the handler.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There is no message identifier.<\/strong> Nothing on the wire tells you this is message 4,182 rather than a repeat of 4,181. If the same push arrives twice, whether from a retry, a proxy, or a browser prefetch of a logged URL, you have no key to deduplicate on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There is no timestamp.<\/strong> The moment of receipt is whatever your server clock says when the request lands. If your handler is queued behind a slow worker, or your endpoint was down and the message is being retried, your stored time is the processing time and not the receive time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There is no signature, token or shared secret in the documented payload.<\/strong> Anything that can reach your URL can post a message into your system that looks exactly like a real one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of those three is a reason to avoid inbound SMS. Each is a reason to build the endpoint a particular way, which is the next section.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"receiving-sms-safely\" class=\"wp-block-heading\">Receiving an SMS Reply Safely<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These six steps produce an endpoint that is correct whether or not the platform ever retries, whether or not the optional fields arrive, and whether or not somebody finds your URL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Put the endpoint on its own path with an unguessable segment.<\/strong> Because the documented payload carries no shared secret, the URL itself is the only thing standing between your inbound table and anyone who can type. Use a path such as <code>\/hooks\/sms\/8f3c1a9d2b7e4056<\/code> rather than <code>\/incoming_sms.php<\/code>. Generate the segment with a cryptographic random source, store it once, and treat it as a credential: never log the full request URL at info level, never paste it into a ticket, and rotate it by adding a second live path before retiring the first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Allowlist by source address at the edge, and ask for the current list in writing.<\/strong> A path secret protects against discovery. A source allowlist protects against a leaked path. Put the check in your load balancer or reverse proxy rather than in application code so that a rejected request never reaches your framework. Fail closed and alert on rejections, because a silent spike in rejections is what a changed sender address looks like.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Return 200 fast and do the work in a queue.<\/strong> Write the raw query string and the arrival time into a durable queue or an append-only table, and return immediately. Every parsing decision, database write and downstream notification happens in a worker. A handler that does real work inline turns a slow database into lost customer replies, and since there is no read endpoint to replay from, a lost reply on this channel is gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Synthesise a deduplication key from the payload plus a coarse time bucket.<\/strong> With no identifier on the wire, build one: hash <code>phoneno<\/code>, <code>content<\/code>, <code>phonecode<\/code>, <code>keyword<\/code> and the arrival time truncated to a sixty second bucket, and make that hash a unique index on your inbound table. A genuine retry inside the bucket collides and is discarded. A customer who really does send the same word twice in the same minute, which is rare and usually a double tap, collides too, and losing that duplicate is a far cheaper error than storing a retry as a second lead. Log every collision so you can see the real rate rather than assume it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Store both an arrival time and a processing time, and never conflate them.<\/strong> Stamp the arrival time in the front-end handler at step three, before the queue. Stamp the processing time in the worker. When they diverge by more than a second you are looking at queue lag, and that divergence is the only signal you will get that your endpoint is struggling, because the platform has no delivery receipt for the push.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Treat every optional parameter as absent until proven present.<\/strong> Four of the six are documented optional. Read them with a default, never with a direct index, and make your keyword routing fall through to a default campaign rather than throwing. A message that arrives without a keyword is still a customer talking to you, and dropping it because a switch statement had no default is the most common way this endpoint loses real leads.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more point that belongs here rather than in a security appendix. Because the push is an HTTP GET, the message body travels in the query string, and query strings are written to access logs by default in nginx, Apache, most reverse proxies and most cloud load balancers. That means your customers&#8217; message text, and their mobile numbers, are sitting in plain text in log files that have a different retention policy and a different access control list from your database. Turn off query string logging on that specific path, or rewrite the logged URL to strip the parameters, and do it before you go live rather than after a log review finds a year of message content.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"whatsapp-inbox\" class=\"wp-block-heading\">The WhatsApp Inbox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>GET https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/inbox<\/code> is read-only and supports <code>action=list<\/code> only. The filter set is the richest of the three REST inboxes.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Filter<\/th><th>Notes<\/th><\/tr><\/thead><tbody><tr><td><code>wabaNumber<\/code><\/td><td>Must belong to the authenticated account<\/td><\/tr><tr><td><code>mobileNo<\/code><\/td><td>The customer&#8217;s number<\/td><\/tr><tr><td><code>isReplied<\/code><\/td><td><code>0<\/code> not replied, <code>1<\/code> replied<\/td><\/tr><tr><td><code>agentId<\/code><\/td><td>Filter to one agent&#8217;s conversations<\/td><\/tr><tr><td><code>waMsgId<\/code><\/td><td>Maximum 50 characters<\/td><\/tr><tr><td><code>fromDate<\/code>, <code>toDate<\/code><\/td><td>Aliases <code>from<\/code> and <code>to<\/code>. A date-only value expands to <code>00:00:00<\/code> and <code>23:59:59<\/code><\/td><\/tr><tr><td><code>page<\/code>, <code>limit<\/code><\/td><td>Default 50, maximum 200<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A documented row:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"incomingId\": \"8801\",\n  \"wabaNumber\": \"9170396xxxxx\",\n  \"mobileNo\": \"919876543210\",\n  \"waMsgId\": \"wamid.HBgMOTEXAMPLE\",\n  \"messageType\": \"text\",\n  \"messageTypeId\": 1,\n  \"profileName\": \"Rahul\",\n  \"message\": \"Hi, I need help with my order\",\n  \"mediaUrl\": \"\",\n  \"isReplied\": 0,\n  \"repliedMedium\": \"\",\n  \"timestamp\": 1752480000000,\n  \"charges\": \"0.00\",\n  \"agentName\": \"NA\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four things on that row deserve attention before you write a mapper for it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>charges<\/code> is the only inbound cost field on the platform. It is a quoted string with two decimals, matching the Telegram delivery row convention and not matching the unquoted float that WhatsApp analytics uses for <code>amount<\/code>. Parse it as a decimal type from the string, never as a float, and never share a numeric parser between this row and the analytics row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>agentName<\/code> is <code>\"NA\"<\/code> in the documented sample, and <code>\"NA\"<\/code> is a string. Whatever language you are in, <code>row.agentName ?? \"unassigned\"<\/code> will happily return <code>\"NA\"<\/code>, and that string then flows into your user interface as an agent name. Normalise it explicitly at the mapper.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>messageType<\/code> and <code>messageTypeId<\/code> are a paired string and integer. Bind the string and ignore the integer, because a string is self-describing in a log and an integer is not. The vocabulary is wider than the send endpoint&#8217;s <code>msgType<\/code> parameter suggests: the platform&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-api-sample-code\/\">WhatsApp sample code builder<\/a> covers text, audio, document, image, video, location, sticker, contact and interactive messages with buttons, so your handler needs a default branch that stores the row and logs the unknown type rather than one that throws.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>waMsgId<\/code> is Meta&#8217;s own identifier in <code>wamid.<\/code> form. It is a fourth identifier namespace on this platform and it links to nothing you hold from an outbound send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented error response is worth memorising because it is the shape that breaks typed clients:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\"status\":\"error\",\"inboxList\":&#91;],\"statusCode\":\"403\",\"reason\":\"WhatsApp product is not enabled for this account.\"}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>403<\/code> there is configuration, not throttling. Never retry it. Surface it to an operator with the <code>reason<\/code> string intact, because the reason string is the entire diagnosis.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rcs-inbox\" class=\"wp-block-heading\">The RCS Inbox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>GET https:\/\/unify.smsgateway.center\/rest\/rcs\/v1\/inbox<\/code> is read-only, <code>action=list<\/code> only, and carries the leanest row of the three.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"incomingId\": \"451\",\n  \"botId\": 3,\n  \"mobileNo\": \"919876543210\",\n  \"msgId\": \"abc-123\",\n  \"type\": \"text\",\n  \"typeLabel\": \"FREE TEXT\",\n  \"message\": \"...\",\n  \"receivedAt\": 1752480000000,\n  \"receivedAtFormatted\": \"...\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two traps live in that row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>incomingId<\/code> is a quoted string while <code>botId<\/code> on the same row is an unquoted integer. This is the platform&#8217;s general habit and it is worth restating: note the JSON type of every field individually, because the types are not consistent even within a single object. A struct that declares both as strings fails to deserialise, and a struct that declares both as integers fails differently.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>message<\/code> is a double-encoded JSON string on RCS and plain text on WhatsApp and Telegram. It echoes the structured RCS payload, so you need a second <code>JSON.parse<\/code>, <code>json.loads<\/code> or equivalent before you can pull out the text a human typed. Wrap that second parse in its own try block: an inbound free-text reply and an inbound suggested-reply postback do not necessarily carry the same inner structure, and a parse failure on one row must not abort the whole page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>typeLabel<\/code> carries <code>FREE TEXT<\/code> in the documented sample, which tells you the platform distinguishes a typed reply from a tapped suggestion. Store both <code>type<\/code> and <code>typeLabel<\/code>, branch on <code>type<\/code>, and display <code>typeLabel<\/code>. For everything else about the RCS surface, including the bot model and the delivery row, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-messaging-api-reference-migration-from-sms\/\">RCS API reference and migration path<\/a> covers the full contract.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is no <code>charges<\/code> field and no media URL field on the RCS inbox row.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"telegram-inbox\" class=\"wp-block-heading\">The Telegram Inbox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>GET https:\/\/unify.smsgateway.center\/rest\/tg\/v1\/inbox<\/code> is read-only, <code>action=list<\/code> only, and has the most filters that map to identity.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"incomingId\": \"101\",\n  \"chatId\": \"123456789\",\n  \"telegramUserId\": \"987654321\",\n  \"firstName\": \"Rahul\",\n  \"username\": \"rahul_demo\",\n  \"phoneNumber\": \"919999999999\",\n  \"message\": \"Hello\",\n  \"msgType\": \"text\",\n  \"mediaUrl\": \"\",\n  \"receivedAt\": 1712345678901,\n  \"receivedAtFormatted\": \"2026-07-07 10:14:38\",\n  \"isRead\": 0\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The single most important fact about this row is that <code>chatId<\/code> and <code>telegramUserId<\/code> are different numbers for the same person, and only <code>chatId<\/code> is addressable. If you store <code>telegramUserId<\/code> as your reply target because it looks like a user identity, every reply you send fails. Store both, reply to <code>chatId<\/code>, and treat <code>telegramUserId<\/code> as an analytics key only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>phoneNumber<\/code> is present on the Telegram row and it is optional in practice, because Telegram only shares a phone number when the user has chosen to share their contact. Do not use it as a join key against your customer database without a fallback, and do not use it as your reply target.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>isRead<\/code> is <code>0<\/code> in the sample. Nothing in the Telegram API sets it. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/telegram-messaging-api-chat-id-model\/\">Telegram API reference<\/a> covers the rest of the family, including the one-bot-per-account model that makes this inbox unscoped.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"row-comparison\" class=\"wp-block-heading\">The Inbound Row, Field by Field<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-inbound-row-comparison.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-inbound-row-comparison.svg\" alt=\"Table diagram comparing nine concepts across SMS, WhatsApp, RCS and Telegram inbound rows, including row identifier, sender identity, provider message identifier, message body encoding, receive timestamp, inbound charge, agent attribution, state flag and media URL.\" class=\"wp-image-2973\"\/><\/a><figcaption class=\"wp-element-caption\">Not one concept in this comparison is expressed identically on all four channels.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Concept<\/th><th>SMS<\/th><th>WhatsApp<\/th><th>RCS<\/th><th>Telegram<\/th><\/tr><\/thead><tbody><tr><td>Row identifier<\/td><td>Not present<\/td><td><code>incomingId<\/code>, quoted string<\/td><td><code>incomingId<\/code>, quoted string<\/td><td><code>incomingId<\/code>, quoted string<\/td><\/tr><tr><td>Sender identity<\/td><td><code>phoneno<\/code>, query parameter<\/td><td><code>mobileNo<\/code>, quoted string<\/td><td><code>mobileNo<\/code>, quoted string<\/td><td><code>chatId<\/code>, quoted string, not a phone number<\/td><\/tr><tr><td>Secondary identity<\/td><td>Not present<\/td><td><code>profileName<\/code><\/td><td>Not present<\/td><td><code>telegramUserId<\/code>, <code>username<\/code>, <code>firstName<\/code>, <code>phoneNumber<\/code><\/td><\/tr><tr><td>Provider message id<\/td><td>Not present<\/td><td><code>waMsgId<\/code>, <code>wamid.<\/code> form<\/td><td><code>msgId<\/code><\/td><td>Not present<\/td><\/tr><tr><td>Destination scoping<\/td><td><code>phonecode<\/code> plus <code>keyword<\/code><\/td><td><code>wabaNumber<\/code><\/td><td><code>botId<\/code>, unquoted integer<\/td><td>Implicit, one bot per account<\/td><\/tr><tr><td>Message body<\/td><td><code>content<\/code>, URL encoded<\/td><td><code>message<\/code>, plain text<\/td><td><code>message<\/code>, double-encoded JSON<\/td><td><code>message<\/code>, plain text<\/td><\/tr><tr><td>Message type<\/td><td>Not present<\/td><td><code>messageType<\/code> plus <code>messageTypeId<\/code><\/td><td><code>type<\/code> plus <code>typeLabel<\/code><\/td><td><code>msgType<\/code><\/td><\/tr><tr><td>Receive timestamp<\/td><td>Not present<\/td><td><code>timestamp<\/code>, epoch ms<\/td><td><code>receivedAt<\/code>, epoch ms, plus formatted twin<\/td><td><code>receivedAt<\/code>, epoch ms, plus formatted twin<\/td><\/tr><tr><td>Media pointer<\/td><td>Not present<\/td><td><code>mediaUrl<\/code><\/td><td>Not present<\/td><td><code>mediaUrl<\/code><\/td><\/tr><tr><td>Inbound charge<\/td><td>Not published<\/td><td><code>charges<\/code>, quoted string, two decimals<\/td><td>Not published<\/td><td>Not published<\/td><\/tr><tr><td>Agent attribution<\/td><td>Not published<\/td><td><code>agentId<\/code>, <code>agentName<\/code><\/td><td>Not published<\/td><td>Not published<\/td><\/tr><tr><td>State flag<\/td><td>Not present<\/td><td><code>isReplied<\/code><\/td><td>Not present<\/td><td><code>isRead<\/code><\/td><\/tr><tr><td>Reply medium<\/td><td>Not present<\/td><td><code>repliedMedium<\/code><\/td><td>Not present<\/td><td>Not present<\/td><\/tr><tr><td>Network metadata<\/td><td><code>location<\/code>, <code>carrier<\/code><\/td><td>Not present<\/td><td>Not present<\/td><td>Not present<\/td><\/tr><tr><td>Formatted timestamp twin<\/td><td>Not present<\/td><td>Not present<\/td><td><code>receivedAtFormatted<\/code><\/td><td><code>receivedAtFormatted<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Scan the first column. Not one row in that table is expressed identically on all four channels. Even <code>incomingId<\/code>, which appears on three of them with the same name and the same JSON type, is absent entirely on the fourth. That is why the design in the &#8220;Designing One Inbound Table&#8221; section maps each channel into your own schema at the edge rather than trying to find a common subset.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two smaller observations worth carrying forward. SMS is the only channel that gives you telecom network metadata, <code>location<\/code> and <code>carrier<\/code>, which is genuinely useful for routing an Indian support queue by circle and is available nowhere else. And WhatsApp is the only channel with a <code>repliedMedium<\/code> field, which tells you how a reply was sent rather than whether it was sent, so it pairs with <code>isReplied<\/code> rather than duplicating it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"identifier-namespaces\" class=\"wp-block-heading\">Four Identifier Namespaces, None of Them Shared<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Across these four surfaces there are four distinct inbound identifier namespaces:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>WhatsApp<\/strong>: <code>waMsgId<\/code> in Meta&#8217;s <code>wamid.<\/code> form, capped at 50 characters as a filter value.<\/li>\n\n\n\n<li><strong>RCS<\/strong>: <code>msgId<\/code>, a short opaque string such as <code>abc-123<\/code> in the documented sample.<\/li>\n\n\n\n<li><strong>Telegram<\/strong>: <code>incomingId<\/code> alone, since no provider-side message identifier is published on the row.<\/li>\n\n\n\n<li><strong>SMS<\/strong>: nothing at all.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">None of these links to any identifier you receive from a send. The outbound identifiers are a separate estate entirely: <code>uuId<\/code> and <code>transactionId<\/code> on SMS, <code>messageId<\/code> on WhatsApp sends with a separate <code>uuId<\/code> on WhatsApp reports, <code>transactionId<\/code> on RCS sends, and one value under three names on Telegram sends. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table article<\/a> covers that estate in full, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report ingestion article<\/a> covers how those identifiers behave once delivery rows start arriving.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The point for an inbound handler is narrow and important: no inbound row on any channel carries a reference to the outbound message that prompted it. Whatever your product wants to do with a reply, the link back has to be built by you.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"threading\" class=\"wp-block-heading\">Threading a Reply to the Message That Caused It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Threading is a correlation problem on every channel here, and the correct solution is the same one that works for delivery report reconciliation: key on a composite you control, not on any single identifier the platform gives you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Build the thread key from the conversation pair plus a time window.<\/strong> For a given channel, the conversation is identified by the recipient identity and the destination scope: <code>(channel, mobileNo, wabaNumber)<\/code> on WhatsApp, <code>(channel, mobileNo, botId)<\/code> on RCS, <code>(channel, chatId)<\/code> on Telegram, and <code>(channel, phoneno, phonecode, keyword)<\/code> on SMS. When an inbound row lands, look up the most recent outbound message to that same conversation pair whose submit time is within a window you choose, and attach the reply to it. Store the resulting link as its own column with a confidence flag, so that when a customer replies to a message from three days ago you can see the weak match rather than silently mis-threading it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Choose the window from your own send pattern, not from a guess about human behaviour.<\/strong> If you send one transactional message per customer per week, a seven day window mis-threads almost nothing. If you send several a day, a two hour window is more honest and the unmatched remainder should go to a human queue rather than being force-fitted. Measure the distribution of your own reply latencies for a fortnight before you fix the number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the unmatched replies.<\/strong> The most valuable inbound messages are frequently the ones that match nothing, because a customer who texts your long code unprompted is a customer who went looking for you. A design that discards unmatched inbound rows because they do not fit a foreign key is throwing away the highest-intent traffic on the channel.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"message-bodies\" class=\"wp-block-heading\">Message Bodies and How They Are Encoded<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three encodings across four channels, and each one needs different handling.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Plain text, WhatsApp and Telegram.<\/strong> Read the <code>message<\/code> field and store it. The only care needed is around length and encoding at the storage layer: use a UTF-8 column wide enough for a full Telegram message, which the platform caps at 4096 characters for text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Double-encoded JSON, RCS.<\/strong> <code>message<\/code> is a JSON string containing the structured RCS payload. Parse the outer response, then parse <code>message<\/code> separately, and put that second parse in its own error boundary so a malformed row fails alone rather than aborting the page.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\n\ndef rcs_body(row):\n    raw = row.get(\"message\") or \"\"\n    if not raw:\n        return {\"text\": \"\", \"raw\": \"\"}\n    try:\n        inner = json.loads(raw)\n    except (ValueError, TypeError):\n        # Store it verbatim rather than losing the customer's words.\n        return {\"text\": raw, \"raw\": raw}\n    return {\"text\": _extract_text(inner), \"raw\": raw}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the fallback. If the inner parse fails, that function returns the raw string as the text rather than an empty result, because a customer message stored in an awkward format is recoverable and a customer message discarded is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>URL-encoded query parameter, SMS.<\/strong> Your web framework decodes this for you, which is exactly where the risk sits, because two things can go wrong silently. Plus signs in the query string decode to spaces, which is correct for this transport and is visible in the documented sample where a sentence arrives as <code>please+call+back+as+am+interested+in+your+services<\/code>. But a genuine plus character a customer typed, in a phone number for instance, arrives percent-encoded, and a hand-rolled parser that splits on <code>&amp;<\/code> and <code>=<\/code> and calls a URL decoder in the wrong order will mangle it. Use your framework&#8217;s query parser rather than writing one, and store the raw query string alongside the decoded fields so that any decoding bug you discover later is repairable from data you already have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Non-Latin scripts deserve a specific note. An inbound Hindi, Tamil or Bengali SMS arrives as percent-encoded UTF-8 bytes in the query string. If your web server, your framework and your database column do not all agree on UTF-8, you get question marks, and you will not notice until a customer complains, because the row count looks correct. Send yourself a Devanagari test message on day one and read it back out of the database rather than out of the log.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"read-state\" class=\"wp-block-heading\">Read State You Can Read but Cannot Write<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Telegram rows carry <code>isRead<\/code>. WhatsApp rows carry <code>isReplied<\/code> and <code>repliedMedium<\/code>. RCS rows carry neither. No endpoint on any of the three sets any of these flags.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Treat all of them as reporting fields owned by the platform&#8217;s own portal and mobile app, which is where <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-whatsapp-inbox-mobile-app\/\">the SMS and WhatsApp inbox features<\/a> live and where an agent tapping a conversation is presumably what moves them. They are genuinely useful as a filter when you want to pull only unanswered conversations, and they are genuinely useless as your own workflow state, because you cannot write them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So keep your own. Add a <code>handled_at<\/code> timestamp and a <code>handled_by<\/code> column to your inbound table and drive your own queue from those. Use the platform flags for reconciliation, in the specific sense of noticing when a conversation is marked replied on the platform but unhandled in your system, which means somebody answered it in the portal and your agents are about to answer it a second time. That divergence report is worth building on day one if any part of your team uses the portal alongside your own tooling.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbound-billing\" class=\"wp-block-heading\">Inbound Billing and Agent Attribution<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WhatsApp inbox rows carry <code>charges<\/code> as a quoted string with two decimals. No other inbound surface publishes a per-message cost. That makes WhatsApp the only channel where receiving a message is visibly a billable event in the data you can read.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical consequence is a reconciliation one. If you total your WhatsApp spend from outbound delivery rows alone, using <code>amount<\/code> from <code>rest\/wa\/v1\/analytics<\/code> or the delivery report, you will not match your invoice, because inbound has its own line. Sum <code>charges<\/code> across the inbox for the same period and add it. Do this before your first invoice query rather than after, because explaining a variance is much harder than never having one. The same WhatsApp analytics surface exposes a <code>billingModel<\/code> field and a <code>groupBy=billable<\/code> grouping, which is the join key for that reconciliation, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp wire contract article<\/a> documents both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Agent attribution is also WhatsApp only. <code>agentId<\/code> is available as a filter and <code>agentName<\/code> appears on the row. Three notes on using it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The literal string <code>\"NA\"<\/code> in <code>agentName<\/code> is the platform&#8217;s null sentinel, and it is the single most likely thing to leak into a dashboard from this row. Normalise it at the mapper, in one place, and add a test.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>agentId<\/code> as a filter is the only way to pull one agent&#8217;s conversations without pulling everything and filtering client side, which matters once your inbox is large enough that 200 rows per page becomes several pages per agent per day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Agent names change. Store <code>agentId<\/code> as the durable key and <code>agentName<\/code> as a display value captured at the time of the row, exactly as you would with any other denormalised label, so that a rename does not rewrite last quarter&#8217;s reports.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"response-envelopes\" class=\"wp-block-heading\">Response Envelopes and the Error Shape<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">All three REST inboxes share one envelope:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"inboxList\": &#91; ],\n  \"totalRecords\": 128,\n  \"page\": 1,\n  \"limit\": 50,\n  \"fromDate\": \"2026-09-01\",\n  \"toDate\": \"2026-09-18\",\n  \"statusCode\": \"200\",\n  \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The list key is named after the resource, so it is <code>inboxList<\/code> on all three here. <code>totalRecords<\/code> gives you the number to page against. <code>statusCode<\/code> is a quoted string carrying <code>\"200\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the error:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\"status\":\"error\",\"inboxList\":&#91;],\"statusCode\":\"403\",\"reason\":\"WhatsApp product is not enabled for this account.\"}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The payload key holds an array in both cases here, which makes the inbox the gentlest member of the family. It is not gentle everywhere: on the same REST generation, <code>rest\/wa\/v1\/analytics<\/code> returns <code>analyticsList<\/code> as an object on success and an empty array on error, which throws in Jackson, <code>encoding\/json<\/code>, <code>serde<\/code> and <code>System.Text.Json<\/code> before your code ever reads the reason string. Because that flip exists elsewhere in the same generation, write the inbox client in the shape that survives it anyway. It costs two lines.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Stage one: loose parse, read only the envelope.\nJsonNode env = mapper.readTree(body);\nif (!\"success\".equals(env.path(\"status\").asText())) {\n    throw new InboxUnavailable(\n        env.path(\"statusCode\").asText(), env.path(\"reason\").asText());\n}\n\/\/ Stage two: bind the typed payload only once success is established.\nInboxPage page = mapper.treeToValue(env, InboxPage.class);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Branch on the top-level <code>status<\/code> string and nothing else. Do not branch on <code>statusCode<\/code>, which is a quoted string on most of the platform and an unquoted integer on <code>WAApi\/send<\/code>, and is renamed to <code>code<\/code> on <code>WAApi\/report<\/code>. Do not parse <code>reason<\/code> or <code>msg<\/code>, which are human-readable strings that can be reworded without notice. Log them, show them to an operator, and never let a regular expression against them decide control flow.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbound-table\" class=\"wp-block-heading\">Designing One Inbound Table for Four Channels<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One table, with channel-specific columns nullable and a raw payload column that is never null.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE inbound_message (\n    id                BIGSERIAL PRIMARY KEY,\n    channel           TEXT        NOT NULL,   -- sms | whatsapp | rcs | telegram\n    platform_row_id   TEXT        NULL,       -- incomingId, null on SMS\n    dedupe_key        TEXT        NOT NULL,   -- synthesised on SMS, derived elsewhere\n    sender_identity   TEXT        NOT NULL,   -- phoneno | mobileNo | chatId\n    sender_secondary  TEXT        NULL,       -- telegramUserId, profileName\n    destination_scope TEXT        NULL,       -- phonecode | wabaNumber | botId\n    routing_keyword   TEXT        NULL,       -- SMS only\n    provider_msg_id   TEXT        NULL,       -- waMsgId | msgId\n    message_type      TEXT        NULL,\n    body_text         TEXT        NOT NULL,\n    media_url         TEXT        NULL,\n    received_at       TIMESTAMPTZ NOT NULL,   -- platform time where published\n    ingested_at       TIMESTAMPTZ NOT NULL,   -- always your clock\n    inbound_charge    NUMERIC(12,4) NULL,     -- WhatsApp only\n    agent_id          TEXT        NULL,\n    agent_name        TEXT        NULL,\n    platform_state    JSONB       NULL,       -- isRead, isReplied, repliedMedium\n    handled_at        TIMESTAMPTZ NULL,       -- yours, not theirs\n    handled_by        TEXT        NULL,\n    thread_message_id BIGINT      NULL,       -- weak link to outbound_message\n    thread_confidence TEXT        NULL,       -- exact | windowed | none\n    raw_payload       JSONB       NOT NULL\n);\n\nCREATE UNIQUE INDEX inbound_dedupe\n    ON inbound_message (channel, dedupe_key);\nCREATE INDEX inbound_conversation\n    ON inbound_message (channel, sender_identity, received_at DESC);\nCREATE INDEX inbound_unhandled\n    ON inbound_message (channel, received_at) WHERE handled_at IS NULL;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Six decisions in that schema are worth defending.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>platform_row_id<\/code> is nullable and <code>dedupe_key<\/code> is not.<\/strong> The identifier you can rely on is the one you construct. On the three REST channels <code>dedupe_key<\/code> is simply the <code>incomingId<\/code>, prefixed by channel. On SMS it is the synthesised hash from the earlier section. The unique index sits on <code>dedupe_key<\/code> so that a re-poll of the same page and a retried push are both idempotent through the same mechanism.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>sender_identity<\/code> holds different kinds of value per channel and that is fine.<\/strong> It holds a phone number on three channels and a Telegram chat identifier on the fourth, which is not a phone number and must never be dialled or messaged as one. The <code>channel<\/code> column beside it tells your reply code which send endpoint to route to, and that pairing is the whole point of keeping them in one table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>received_at<\/code> and <code>ingested_at<\/code> are both mandatory.<\/strong> On the three REST channels <code>received_at<\/code> comes from the platform in epoch milliseconds. On SMS there is no platform time, so set <code>received_at<\/code> to the arrival time you stamped at the edge and rely on <code>ingested_at<\/code> to expose queue lag. Never let a null timestamp reach this table, because a null sorts unpredictably and inbound queues are sorted by time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>inbound_charge<\/code> is <code>NUMERIC<\/code> and is populated from a string.<\/strong> WhatsApp publishes <code>charges<\/code> as a quoted string. Parse the string into a decimal type rather than a float, because this column gets summed against an invoice and binary floating point accumulates error exactly where you will be asked to explain a rupee.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>platform_state<\/code> is JSONB rather than three boolean columns.<\/strong> <code>isRead<\/code>, <code>isReplied<\/code> and <code>repliedMedium<\/code> are read-only, channel-specific, and likely to grow. Keeping them in a JSON column signals clearly that they are theirs and not yours, and stops anyone writing an <code>UPDATE<\/code> against something the platform will overwrite at the next poll.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>raw_payload<\/code> is <code>NOT NULL<\/code> on every channel including SMS.<\/strong> On the REST channels it is the row as received. On SMS it is the full query string. Every field mapping decision above is reversible as long as the raw payload is there, and none of them are if it is not. Storage is the cheapest insurance you will ever buy on a messaging integration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the outbound side of this schema, and how <code>thread_message_id<\/code> points into it, see the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table design<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"polling-loop\" class=\"wp-block-heading\">The Polling Loop That Does Not Lose Messages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three inboxes, all newest first, all paged, none with a cursor. That last point is the one that decides the algorithm.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The WhatsApp delivery report exposes <code>startCursor<\/code> and the SMS delivery report exposes hex <code>cursors<\/code>, so cursor pagination exists on this platform. The inboxes do not use it. They use <code>page<\/code> and <code>limit<\/code> against a date range, ordered newest first, which means the result set shifts under you: if two new messages arrive between your request for page one and your request for page two, two messages that were at the top of page two are now at the bottom of page one, and you never see them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to anchor on time rather than on page position.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Poll a closed date range that ends in the past.<\/strong> Set <code>toDate<\/code> to a moment slightly behind now, a minute is plenty, and <code>fromDate<\/code> to the end of your previous successful poll. A range that ends in the past does not grow while you page through it, so page two means the same thing it meant when you read page one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Page until you have <code>totalRecords<\/code> rows, not until you get a short page.<\/strong> <code>totalRecords<\/code> is in the envelope. Use it. A short page is a reasonable termination signal on a stable dataset and a bug on a shifting one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Overlap your windows deliberately.<\/strong> Start each poll a few minutes before the end of the last one. Duplicate rows are free because the unique index on <code>dedupe_key<\/code> discards them, and the overlap covers clock skew between your server and the platform, which is unstated and therefore not zero.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Persist the watermark only after the whole window is committed.<\/strong> If page three of five fails, the next run must redo the entire window. A watermark advanced per page turns a partial failure into permanently missing messages.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Set <code>limit<\/code> to 200 and respect it.<\/strong> It is the documented maximum on all three. Fewer requests for the same data means fewer chances to fail mid-window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Poll each channel independently, with its own watermark and its own failure state.<\/strong> A WhatsApp <code>403<\/code> because the product is not enabled must not stop Telegram polling. Separate watermarks also let you back off one channel without touching the others. On the subject of pacing, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rate-limiting-backpressure-messaging-systems\/\">rate limiting and backpressure article<\/a> covers the general shape.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def poll_window(channel, since, until, fetch):\n    page, seen, total = 1, 0, None\n    rows = &#91;]\n    while True:\n        env = fetch(action=\"list\", fromDate=since, toDate=until,\n                    page=page, limit=200, output=\"json\")\n        if env.get(\"status\") != \"success\":\n            raise InboxUnavailable(env.get(\"statusCode\"), env.get(\"reason\"))\n        batch = env.get(f\"{channel}List\") or env.get(\"inboxList\") or &#91;]\n        rows.extend(batch)\n        total = int(env.get(\"totalRecords\") or 0)\n        seen += len(batch)\n        if seen &gt;= total or not batch:\n            break\n        page += 1\n    return rows  # caller commits all rows, then advances the watermark<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>or []<\/code> on the list key is doing real work. It is what keeps this loop alive on an error shape that returns an empty array where an object was expected, and it costs nothing on the success path.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"language-samples\" class=\"wp-block-heading\">Four Language Samples, Four Different Traps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each sample below is minimal and each one demonstrates a trap that bites in that specific language.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>cURL, and the trap is credentials in the URL.<\/strong> Several published samples across the platform put <code>userid<\/code> and <code>password<\/code> in a GET query string. Do not copy that pattern. The <code>apikey<\/code> request header is accepted on every endpoint of every API on this platform, and it keeps the credential out of your shell history, out of your proxy logs and out of the platform&#8217;s access logs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -G 'https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/inbox' \\\n  -H 'apikey: YOUR_API_KEY' \\\n  --data-urlencode 'userid=YOUR_USER_ID' \\\n  --data-urlencode 'action=list' \\\n  --data-urlencode 'wabaNumber=9170396xxxxx' \\\n  --data-urlencode 'fromDate=2026-09-17 00:00:00' \\\n  --data-urlencode 'toDate=2026-09-18 00:00:00' \\\n  --data-urlencode 'page=1' \\\n  --data-urlencode 'limit=200' \\\n  --data-urlencode 'output=json'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that <code>userid<\/code> still travels alongside the header. On this platform a token is an authorization and never an identity, which the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/oauth-messaging-connect-customer-sms-account\/\">OAuth for messaging platforms article<\/a> covers in full.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Node.js, and the trap is long numeric identifiers.<\/strong> <code>JSON.parse<\/code> silently rounds any integer beyond <code>Number.MAX_SAFE_INTEGER<\/code>, and the WhatsApp delivery report publishes <code>uuId<\/code> as an unquoted nineteen digit number. The inbox row publishes its identifiers as quoted strings, which is safe, but your inbound handler will inevitably join against report data that is not, so build the defence into the shared HTTP layer rather than per endpoint.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Quote any unquoted integer of 16 digits or more before parsing.\nconst safeParse = (body) =&gt;\n  JSON.parse(body.replace(\/:\\s*(\\d{16,})(?=\\s*&#91;,}])\/g, ': \"$1\"'));\n\nconst res = await fetch(url, { headers: { apikey: process.env.SGC_API_KEY } });\nconst env = safeParse(await res.text());\nif (env.status !== 'success') {\n  throw new Error(`${env.statusCode}: ${env.reason}`);\n}\nfor (const row of env.inboxList ?? &#91;]) {\n  \/\/ agentName is the string \"NA\", not null. Normalise here, once.\n  const agent = row.agentName &amp;&amp; row.agentName !== 'NA' ? row.agentName : null;\n  await store({ ...row, agentName: agent });\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js integration tutorial<\/a> covers the rest of the JavaScript-specific hazards on this platform.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>PHP, and the trap is the inbound SMS endpoint itself.<\/strong> This is the handler shape that steps one through six in the earlier section produce. It accepts either method, returns immediately, and defers everything.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\/\/ Route: \/hooks\/sms\/8f3c1a9d2b7e4056  (path segment is a credential)\n\n$arrivedAt = microtime(true);\n$raw       = $_SERVER&#91;'QUERY_STRING'] ?? '';\n$in        = $_REQUEST;  \/\/ GET or POST, per the documented handler note\n\n$phoneno = $in&#91;'phoneno'] ?? '';\n$content = $in&#91;'content'] ?? '';\n\nif ($phoneno === '' || $content === '') {\n    http_response_code(200);   \/\/ acknowledge, then investigate offline\n    error_log('inbound_sms: missing required field');\n    exit;\n}\n\n$bucket = intdiv((int) $arrivedAt, 60);\n$dedupe = hash('sha256', implode(\"\\x1f\", &#91;\n    $phoneno,\n    $content,\n    $in&#91;'phonecode'] ?? '',\n    $in&#91;'keyword']   ?? '',\n    (string) $bucket,\n]));\n\nenqueue_inbound(&#91;\n    'channel'           =&gt; 'sms',\n    'dedupe_key'        =&gt; $dedupe,\n    'sender_identity'   =&gt; $phoneno,\n    'destination_scope' =&gt; $in&#91;'phonecode'] ?? null,\n    'routing_keyword'   =&gt; $in&#91;'keyword']   ?? null,\n    'body_text'         =&gt; $content,\n    'received_at'       =&gt; $arrivedAt,\n    'raw_payload'       =&gt; $raw,\n]);\n\nhttp_response_code(200);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details in there are deliberate. The handler returns <code>200<\/code> even when required fields are missing, because a non-200 invites a retry of a payload that will fail identically, and the investigation belongs in your logs rather than on the wire. And the dedupe hash joins its parts with a unit separator character rather than a comma, so that a message body containing a comma cannot collide with a different field boundary. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP integration tutorial<\/a> covers the outbound side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Java, and the trap is strict deserialisation.<\/strong> Jackson throws on an unexpected JSON type and on an unknown property. The first is what breaks you on an error response; the second is what breaks you when the platform adds a field, and it will, because these responses already carry presentation data such as formatted timestamp twins.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ObjectMapper mapper = new ObjectMapper()\n    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);\n\nJsonNode env = mapper.readTree(body);\nif (!\"success\".equals(env.path(\"status\").asText())) {\n    throw new InboxUnavailable(\n        env.path(\"statusCode\").asText(), env.path(\"reason\").asText());\n}\n\nfor (JsonNode row : env.path(\"inboxList\")) {\n    InboundRow r = mapper.treeToValue(row, InboundRow.class);\n    \/\/ charges is a quoted string. BigDecimal from the string, never double.\n    BigDecimal charge = row.hasNonNull(\"charges\")\n        ? new BigDecimal(row.get(\"charges\").asText())\n        : null;\n    store(r, charge);\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Bind only the fields you use, ignore unknown properties, and never build a feature on a field whose purpose you cannot state in one sentence. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-java-spring-boot-integration\/\">Java and Spring Boot integration article<\/a> has the full pattern.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten Mistakes That Cost People Messages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Writing one inbound handler for all four channels.<\/strong> There is no shared subset. Map each channel at the edge into your own schema.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Treating the SMS push as reliable delivery.<\/strong> One attempt, no receipt, no read endpoint. Your endpoint&#8217;s availability is the only thing standing between a customer reply and nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Leaving query string logging on for the SMS endpoint.<\/strong> Message text and mobile numbers land in access logs under a different retention policy than your database.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Replying to <code>telegramUserId<\/code>.<\/strong> It is not addressable. <code>chatId<\/code> is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Trusting <code>agentName<\/code> to be null when unassigned.<\/strong> It is the string <code>\"NA\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Paging an inbox that is sorted newest first over an open-ended date range.<\/strong> New arrivals shift rows across page boundaries and you lose the ones that move. Close the window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Advancing the poll watermark per page instead of per window.<\/strong> A mid-window failure then skips everything after the failure point permanently.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Parsing <code>charges<\/code> as a float.<\/strong> It is a quoted string and it gets summed against an invoice. Use a decimal type.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Forgetting a second parse on the RCS <code>message<\/code> field.<\/strong> It is a double-encoded JSON string, and a single parse hands you a string that looks like a body and is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Excluding WhatsApp inbound from your cost model.<\/strong> It is the only channel that publishes an inbound charge, and it is the only one that will surprise you at invoice time.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"production-checklist\" class=\"wp-block-heading\">Production Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The SMS endpoint<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Endpoint path contains an unguessable random segment generated from a cryptographic source<\/li>\n\n\n\n<li>Source address allowlist enforced at the proxy or load balancer, failing closed<\/li>\n\n\n\n<li>Both GET and POST accepted, parameters read from whichever collection carries them<\/li>\n\n\n\n<li>Handler returns 200 within a fixed small budget and defers all work to a queue<\/li>\n\n\n\n<li>Query string logging disabled or rewritten for this path specifically<\/li>\n\n\n\n<li>Deduplication key synthesised from payload plus a coarse time bucket<\/li>\n\n\n\n<li>Unique index on the deduplication key, with collisions logged rather than silenced<\/li>\n\n\n\n<li>Arrival time stamped at the edge, processing time stamped in the worker, both stored<\/li>\n\n\n\n<li>Every optional parameter read with a default, keyword routing has a fallback branch<\/li>\n\n\n\n<li>Raw query string stored verbatim on every row<\/li>\n\n\n\n<li>A Devanagari or other non-Latin test message sent and read back out of the database<\/li>\n\n\n\n<li>Alert on zero inbound messages over a period longer than your quietest normal gap<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The three REST inboxes<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>apikey<\/code> header used on every request, <code>userid<\/code> sent alongside it<\/li>\n\n\n\n<li>No credential appears in any query string in any environment<\/li>\n\n\n\n<li><code>limit<\/code> set to 200<\/li>\n\n\n\n<li>Poll window is closed, ending at least a minute in the past<\/li>\n\n\n\n<li>Windows overlap the previous window by several minutes<\/li>\n\n\n\n<li>Paging terminates on <code>totalRecords<\/code>, not on a short page<\/li>\n\n\n\n<li>Watermark advanced only after the entire window is committed<\/li>\n\n\n\n<li>Each channel has its own watermark and its own failure state<\/li>\n\n\n\n<li>Client branches on the top-level <code>status<\/code> string only, never on <code>statusCode<\/code>, <code>code<\/code>, <code>reason<\/code> or <code>msg<\/code><\/li>\n\n\n\n<li>Deserialiser configured to ignore unknown properties<\/li>\n\n\n\n<li>Payload key read defensively so an empty array where an object was expected does not throw<\/li>\n\n\n\n<li><code>403<\/code> treated as configuration and never retried, with <code>reason<\/code> surfaced verbatim to an operator<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The data model<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>One inbound table, channel column, channel-specific columns nullable<\/li>\n\n\n\n<li><code>raw_payload<\/code> populated and non-null on every channel including SMS<\/li>\n\n\n\n<li><code>received_at<\/code> and <code>ingested_at<\/code> both populated on every row<\/li>\n\n\n\n<li><code>inbound_charge<\/code> stored as a decimal type, parsed from the string<\/li>\n\n\n\n<li><code>agentName<\/code> normalised so the literal <code>\"NA\"<\/code> never reaches a user interface<\/li>\n\n\n\n<li>Platform read-state flags stored as read-only data, separate from your own handling state<\/li>\n\n\n\n<li>Your own <code>handled_at<\/code> and <code>handled_by<\/code> columns drive your agent queue<\/li>\n\n\n\n<li>Thread link stored with an explicit confidence value, unmatched rows retained<\/li>\n\n\n\n<li>WhatsApp inbound charges summed into your cost reporting alongside outbound<\/li>\n\n\n\n<li>Divergence report comparing platform <code>isReplied<\/code> against your own <code>handled_at<\/code><\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The behaviours below are not pinned down by anything you can read. Each item gives you the choice that stays correct whichever way the behaviour actually resolves, so none of them require you to wait for an answer before shipping.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Assume the SMS push is delivered at most once, and make the endpoint survive both outcomes.<\/strong> Whether the platform retries a failed push, and how many times, is not something you want to discover during an outage. Build for both: the synthesised deduplication key from the earlier section makes retries harmless, and returning 200 from a queue-backed handler makes retries unnecessary. A design that is correct under at-most-once and at-least-once delivery does not need to know which one it got.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Store epoch timestamps and render them in a timezone you set explicitly.<\/strong> The RCS and Telegram rows publish both a raw epoch millisecond value and a preformatted twin such as <code>2026-07-07 10:14:38<\/code>, with no offset attached. Store the epoch, which is unambiguous, and format for display in your own application timezone. Use the preformatted twin only to eyeball a response during development, never as a parsed value in production.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Treat message type as an open vocabulary with a default branch.<\/strong> The types you will see on WhatsApp span at least text, image, video, audio, document, location, sticker, contact and interactive button replies. Rather than enumerating them in a switch that throws, store <code>messageType<\/code> verbatim, branch on the handful you render specially, and let everything else fall through to a row that is stored, logged and visible to a human. A new type then costs you a log line instead of a dropped message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Poll a closed, overlapping window rather than assuming your clock matches theirs.<\/strong> The offset between your server clock and the platform&#8217;s row timestamps is not stated. An overlap of a few minutes on every poll makes the question irrelevant, and the unique index makes the duplicate rows free.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Scope every WhatsApp inbox call by <code>wabaNumber<\/code> even when you only have one.<\/strong> Whether an unscoped call returns every number on the account or rejects the request is not worth finding out in production on the day a second number is provisioned. Pass it explicitly from configuration, and your code keeps working unchanged when the second number arrives.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Filter by <code>isReplied<\/code> or <code>isRead<\/code> for reporting only, and drive your queue from your own column.<\/strong> Since no endpoint writes these flags, what exactly flips them is outside your control. A workflow that depends on them is a workflow whose state can change without your code running. Read them, reconcile against them, and never branch your agent assignment on them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Cache inbound media to your own storage as soon as you see a <code>mediaUrl<\/code>.<\/strong> How long a media URL stays live, and whether it requires the same credentials as the inbox call, are not stated. Download on first sight, store in your own bucket, and keep the original URL in the raw payload. This also removes a runtime dependency from your agent interface, which otherwise renders a broken image at exactly the moment somebody needs to see it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Key SMS campaign routing on the keyword, and make the empty keyword a first-class case.<\/strong> The keyword is documented as optional, which means a message can reach your handler with nothing to route on. Send those to a default queue with the long code recorded, rather than dropping them. A customer who texts your number without the magic word is still a customer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Treat <code>location<\/code> and <code>carrier<\/code> as advisory, never as authoritative.<\/strong> They are documented as optional on the SMS push, and what fills them for a number that has ported operators or roamed is not stated. They are excellent for a rough routing heuristic, such as putting a Mumbai circle reply in front of a Mumbai agent, and poor as a stored fact about a customer. Store them on the row, not on the customer record.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Do not assume the three inbox <code>List<\/code> keys behave identically on error just because they do on success.<\/strong> Elsewhere in the same REST generation a payload key returns an object on success and an empty array on error. Reading the list key with a null-safe default, as the polling loop above does, costs one operator and makes the question moot on every channel at once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Reconcile your inbound row count against the portal once per week for the first month.<\/strong> Whether any filtering happens between a message arriving and appearing in the API is not something you can verify from your own side alone. A weekly count comparison catches a systematic gap in the first week rather than the first quarter, and once two consecutive weeks match you can retire the check.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Build your inbound cost model to accept a charge on any channel, not only WhatsApp.<\/strong> Only WhatsApp publishes <code>charges<\/code> on an inbound row today. Make the column nullable and channel-agnostic, sum whatever is present, and your reporting keeps working unchanged if another channel starts publishing one. The alternative, a WhatsApp-specific cost path, is a rewrite the day that changes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I receive SMS replies through an API call instead of a webhook?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. There is no inbound SMS read endpoint in the developer API. Inbound SMS is delivered by an HTTP GET push to a URL you nominate, carrying <code>phonecode<\/code>, <code>keyword<\/code>, <code>phoneno<\/code>, <code>content<\/code>, <code>location<\/code> and <code>carrier<\/code>. If you need a queryable history of inbound SMS, you build it yourself by storing every push as it arrives, which is why the endpoint design in this guide emphasises durability over cleverness.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do WhatsApp, RCS or Telegram push inbound messages to a webhook?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Not through the endpoints documented in the developer API. All three expose read-only REST inboxes that you poll: <code>rest\/wa\/v1\/inbox<\/code>, <code>rest\/rcs\/v1\/inbox<\/code> and <code>rest\/tg\/v1\/inbox<\/code>. This is the mirror image of delivery reports, where SMS is the only channel with an outbound push and every channel offers a poll.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the fastest I can see an inbound message on WhatsApp, RCS or Telegram?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your polling interval sets the floor. With a closed window ending a minute in the past and a one minute poll, worst-case latency is roughly two minutes. Tightening the trailing edge below a minute increases the chance of racing rows that are still being written, so the safer way to reduce latency is a shorter interval rather than a tighter window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many inbound messages can I pull in one request?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Up to 200. <code>limit<\/code> defaults to 50 on all three inboxes and 200 is the documented maximum. Use <code>totalRecords<\/code> from the envelope to know how many pages you need.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does receiving a message cost me anything?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On WhatsApp, yes, and the row tells you: inbox rows carry a <code>charges<\/code> field as a quoted string. No other inbound surface publishes a per-message inbound cost. Include the WhatsApp inbound total in your cost model or your reconciliation will not match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I know which outbound message a reply is answering?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You work it out yourself. No inbound row on any channel carries a reference to an outbound message. Correlate on the conversation pair, meaning the sender identity plus the destination scope, and the most recent outbound message inside a time window you choose from your own send pattern. Store the link with an explicit confidence value and keep the unmatched replies.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my Telegram reply fail even though I stored the user identity from the inbox?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Almost certainly because you stored <code>telegramUserId<\/code> rather than <code>chatId<\/code>. They are different numbers for the same person and only <code>chatId<\/code> is addressable. Store both and reply to <code>chatId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I mark an inbound message as read or replied through the API?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No endpoint sets <code>isRead<\/code> on Telegram or <code>isReplied<\/code> on WhatsApp. Both are readable and filterable, and useful for reconciling against portal activity, but your own workflow state needs its own columns in your own table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why is my agent name showing as NA?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because <code>agentName<\/code> uses the literal string <code>\"NA\"<\/code> as its null sentinel, and a null check passes it straight through. Normalise it in your mapper, in one place, and add a test so it cannot come back.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The RCS message field looks like JSON rather than the customer&#8217;s text. Is that a bug?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No, it is the contract. The RCS inbox <code>message<\/code> field is a double-encoded JSON string carrying the structured payload. Parse the response, then parse <code>message<\/code> separately, and keep that second parse inside its own error boundary so one malformed row does not abort the page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens if my SMS endpoint is down when a reply arrives?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Plan for the message being gone, because there is no read endpoint to recover it from. That is the single strongest argument for the queue-backed handler in this guide: the only work done in the request path is writing the raw payload and returning 200, which keeps the endpoint available under conditions that would take a heavier handler offline.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I filter the inboxes by date only, without a time?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes on WhatsApp, where a date-only <code>fromDate<\/code> expands to <code>00:00:00<\/code> and a date-only <code>toDate<\/code> expands to <code>23:59:59<\/code>. Send full timestamps anyway. They are accepted everywhere, they make your poll window explicit, and they remove any question about which boundary a row on the edge falls on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need a dedicated long code to receive SMS, or will a shared one work?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both are offered. The operational difference that matters to your code is the keyword: on a shared number the keyword is what separates your traffic from everyone else&#8217;s, so treat it as load bearing and handle its absence deliberately. On a dedicated number every message arriving at that code is yours, and keywords become a routing convenience rather than a necessity.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there a sandbox for testing inbound?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The platform offers a sandbox mode where delivery is simulated, described on the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">demo page<\/a>. For inbound specifically, the most reliable test is the one the documented SMS handler recommends: call your own endpoint with the full sample query string first, confirm your parsing and storage, and only then hand the URL over. For the three REST inboxes, a successful <code>action=list<\/code> call that returns <code>\"status\": \"success\"<\/code> with zero rows is a valid smoke test that your credentials and product entitlement are in order.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Building two-way messaging across SMS, WhatsApp, RCS or Telegram?<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Create a free account and read the full endpoint reference in the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">developer documentation<\/a>, or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">get in touch<\/a> to talk through long code and keyword provisioning for your use case.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Recent Articles<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp Business API Wire Contract: Ten Endpoints, Two Base Paths, Five Envelopes<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/telegram-messaging-api-chat-id-model\/\">Telegram Messaging API: Eight Endpoints, the Chat ID Model, and the Error Shape That Breaks Typed Clients<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-messaging-api-reference-migration-from-sms\/\">RCS Messaging API: Complete Reference and Migration Path from SMS<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">Delivery Report Ingestion: Building a System of Record, Not a Dashboard<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contact-groups-segments-data-model-group-send\/\">Contact Groups and Segments: The Data Model Behind a Group Send<\/a><\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>SMS, WhatsApp, RCS and Telegram each deliver inbound messages differently. One pushes and cannot be read. Three can be read and never push. Here is every field, every type and every trap, compared side by side.<\/p>\n","protected":false},"author":118,"featured_media":2971,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2255,2254,474,811,481,2252,472,2253,632],"class_list":["post-2970","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-developer-api","tag-inbound-messaging","tag-long-code-sms","tag-rcs-messaging","tag-sms-api","tag-telegram","tag-two-way-sms","tag-webhook","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2970","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2970"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2970\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2971"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2970"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2970"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2970"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}