{"id":2966,"date":"2026-09-16T11:16:53","date_gmt":"2026-09-16T05:46:53","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2966"},"modified":"2026-09-16T11:16:56","modified_gmt":"2026-09-16T05:46:56","slug":"whatsapp-business-api-wire-contract","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/","title":{"rendered":"WhatsApp Business API Wire Contract: Ten Endpoints, Two Base Paths, Five Envelopes"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A field-level reference to the WhatsApp Business API on SMSGatewayCenter. Ten operations across two base paths, five distinct response envelopes, the media and template lifecycles, the delivery row compared against SMS, RCS and Telegram, and the parsing traps that silently corrupt identifiers.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/whatsapp-business-api-wire-contract.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/whatsapp-business-api-wire-contract-1024x584.webp\" alt=\"Four parallel messaging channels converging into a single routing layer, illustrating four different API contracts served by one platform.\" class=\"wp-image-2967\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/whatsapp-business-api-wire-contract-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/whatsapp-business-api-wire-contract-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/whatsapp-business-api-wire-contract-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/whatsapp-business-api-wire-contract.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Four outbound channels, four different wire contracts, one platform.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\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=\"#two-base-paths\">Two Base Paths, One Product<\/a><\/li>\n\n\n\n<li><a href=\"#endpoint-reference\">The Ten Operation Reference<\/a><\/li>\n\n\n\n<li><a href=\"#send-contract\">The Send Contract and Why It Is the Outlier<\/a><\/li>\n\n\n\n<li><a href=\"#five-envelopes\">Five Response Envelopes in One Family<\/a><\/li>\n\n\n\n<li><a href=\"#success-codes\">The Success Code Tabulation<\/a><\/li>\n\n\n\n<li><a href=\"#payload-type-flip\">The Error Payload That Changes Type<\/a><\/li>\n\n\n\n<li><a href=\"#media-lifecycle\">The Media Lifecycle, Start to Finish<\/a><\/li>\n\n\n\n<li><a href=\"#template-management\">Template Management End to End<\/a><\/li>\n\n\n\n<li><a href=\"#two-approval-states\">Two Approval States, Not One<\/a><\/li>\n\n\n\n<li><a href=\"#delivery-row-four-ways\">The Delivery Row Compared Four Ways<\/a><\/li>\n\n\n\n<li><a href=\"#nineteen-digit-number\">The Nineteen Digit Number That Breaks JavaScript<\/a><\/li>\n\n\n\n<li><a href=\"#inbox-and-charges\">The Inbox, and What It Costs You<\/a><\/li>\n\n\n\n<li><a href=\"#analytics-denominator\">Analytics and the Right Denominator<\/a><\/li>\n\n\n\n<li><a href=\"#first-send\">Your First Send in Six Steps<\/a><\/li>\n\n\n\n<li><a href=\"#four-languages\">Four Languages, Four Traps<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes That Cost Real Money<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Choosing WhatsApp Over SMS, RCS or Telegram<\/a><\/li>\n\n\n\n<li><a href=\"#production-checklist\">The 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\">The WhatsApp Business API on SMSGatewayCenter is ten operations spread across two API generations, and the single most important thing to know before you write any code is that they do not share a response envelope. The older generation lives on <code>https:\/\/unify.smsgateway.center\/WAApi\/<\/code> and covers four resources: <code>send<\/code>, <code>media<\/code>, <code>template<\/code> and <code>report<\/code>. The newer generation lives on <code>https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/<\/code> and covers <code>inbox<\/code> and <code>analytics<\/code>. Both generations take <code>output=json<\/code>. Both accept the <code>apikey<\/code> request header, which is what you should use, with <code>userid<\/code> sent alongside it every time. Two of the ten operations report success through a field that is not called <code>statusCode<\/code>, or that is an integer rather than a string, so branch on the top level <code>status<\/code> string and nothing else. <code>WAApi\/media<\/code> and <code>WAApi\/template<\/code> are the only genuinely REST-shaped resources on the whole platform: one URL each, with the HTTP method selecting the operation, POST to create, GET to read, DELETE to remove. And the delivery report returns <code>uuId<\/code> as an unquoted nineteen digit JSON number, which loses precision the moment a JavaScript client calls <code>JSON.parse<\/code> on it.<\/p>\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>Two base paths serve one product. <code>WAApi\/<\/code> is the older generation, <code>rest\/wa\/v1\/<\/code> the newer one. Both use <code>output<\/code>, never <code>format<\/code>.<\/li>\n\n\n\n<li><code>WAApi\/media<\/code> and <code>WAApi\/template<\/code> each expose one URL where the HTTP verb is the operation. POST creates, GET reads, DELETE removes. No <code>action<\/code> parameter is involved.<\/li>\n\n\n\n<li>Five distinct response envelopes exist inside this one family. A generic list unwrapper written for one of them will fail on the other four.<\/li>\n\n\n\n<li><code>WAApi\/send<\/code> reports <code>statusCode<\/code> as an unquoted integer <code>200<\/code> and names its text field <code>description<\/code>. <code>WAApi\/report<\/code> names its code field <code>code<\/code>, also an unquoted integer. The other eight return a quoted <code>\"200\"<\/code> in <code>statusCode<\/code>.<\/li>\n\n\n\n<li>On error the payload key comes back as an empty array, even on the endpoints that return an object on success. Parse loosely, read <code>status<\/code>, return early on failure, and bind the typed payload only after that check.<\/li>\n\n\n\n<li><code>uuId<\/code> on a delivery row is an unquoted nineteen digit number. <code>mobileNo<\/code> and <code>wabaNumber<\/code> are unquoted too. Any of these will be silently corrupted by a standard JavaScript parse.<\/li>\n\n\n\n<li><code>submitTime<\/code> is a quoted string on the same row where <code>deliveryTime<\/code> and <code>readTime<\/code> are unquoted integers.<\/li>\n\n\n\n<li>Template creation and template deletion both return <code>\"Accepted for processing.\"<\/code> Neither is complete when the call returns. Media deletion, by contrast, returns <code>\"success\"<\/code> and is done.<\/li>\n\n\n\n<li>A template carries two independent approval states, <code>whatsAppStatus<\/code> and <code>systemStatus<\/code>. Both must be satisfied before the template is usable.<\/li>\n\n\n\n<li>Placeholders in a WhatsApp template are <code>{{1}}<\/code>, <code>{{2}}<\/code>, <code>{{3}}<\/code>, numbered and doubled. That is a different syntax from every other channel on the platform.<\/li>\n\n\n\n<li>Inbox rows carry a <code>charges<\/code> field, so inbound traffic is a billable line item you should be reconciling.<\/li>\n\n\n\n<li><code>rest\/wa\/v1\/analytics<\/code> caps its date range at 365 days. That is twelve times wider than the 31 day cap on the SMS dashboard, so WhatsApp is the one channel where you can pull a full year in a single call.<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-whatsapp-api-surface.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-whatsapp-api-surface.svg\" alt=\"Diagram of the WhatsApp Business API surface on SMSGatewayCenter showing two base paths, ten operations and five response envelopes.\" class=\"wp-image-2968\"\/><\/a><figcaption class=\"wp-element-caption\">Ten operations, two generations, five envelopes. The HTTP method is the operation selector on media and template.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-base-paths\" class=\"wp-block-heading\">Two Base Paths, One Product<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before you design a client, establish which generation each call belongs to, because the generation determines the envelope, the paging model and the error shape.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Generation one sits under <code>WAApi\/<\/code>. It carries <code>send<\/code>, <code>media<\/code>, <code>template<\/code> and <code>report<\/code>. It is form encoded or multipart, it uses cursor paging where it pages at all, and its samples are written as PHP cURL blocks. Generation two sits under <code>rest\/wa\/v1\/<\/code>, with a legacy alias that drops the <code>v1<\/code> segment. It carries <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-inbox\/\">the inbox<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-analytics\/\">analytics<\/a>, both GET only, both offset paged on <code>page<\/code> and <code>limit<\/code>, and both documenting the <code>apikey<\/code> header variant explicitly in their own samples.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That split will look familiar if you have worked with RCS on this platform, where an older <code>RCSApi\/<\/code> generation coexists with a newer <code>rest\/rcs\/v1\/<\/code> one. There is one difference worth internalising. On RCS the two generations disagree about what the response format parameter is called: the old one wants <code>format<\/code>, the new one wants <code>output<\/code>. On WhatsApp both generations want <code>output<\/code>. So you can set <code>output=json<\/code> once in a shared request builder for this channel and never think about it again, which is not true of RCS.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical consequence is that a WhatsApp client should be organised as two transports, not one. Transport A speaks form encoding and multipart to <code>WAApi\/<\/code>. Transport B speaks query parameters to <code>rest\/wa\/v1\/<\/code>. Both share an auth decorator that sets the <code>apikey<\/code> header and appends <code>userid<\/code>. Everything below that split is generation specific.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Authentication, said once<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Send your API key in the <code>apikey<\/code> request header and include <code>userid<\/code> as a parameter alongside it. Do not put a password in a query string, and do not copy a sample that does. On this platform a key is an authorization and never an identity, which is why <code>userid<\/code> remains mandatory even when the header is present. The full set of mechanisms, including the Basic Auth session token and the OAuth flow, is on <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/authentication\/\">the authentication page<\/a>, and there is a walkthrough of key generation in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/create-use-sms-gateway-center-api-key\/\">Create and Use Your SMSGatewayCenter API Key<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"endpoint-reference\" class=\"wp-block-heading\">The Ten Operation Reference<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Operation<\/th><th>Path<\/th><th>Method<\/th><th>Key parameters<\/th><th>Payload key<\/th><\/tr><\/thead><tbody><tr><td>Send a message<\/td><td><code>WAApi\/send<\/code><\/td><td>POST<\/td><td><code>wabaNumber<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>sendMethod<\/code>, <code>msgType<\/code>, <code>templateName<\/code><\/td><td>flat, no payload key<\/td><\/tr><tr><td>Upload media<\/td><td><code>WAApi\/media<\/code><\/td><td>POST<\/td><td><code>wabaNumber<\/code>, <code>mediaType<\/code>, <code>identifier<\/code>, <code>description<\/code>, file part<\/td><td><code>mediaId<\/code>, <code>mediaUrl<\/code><\/td><\/tr><tr><td>Read media library<\/td><td><code>WAApi\/media<\/code><\/td><td>GET<\/td><td>none beyond auth<\/td><td><code>mediaList<\/code><\/td><\/tr><tr><td>Read one media item<\/td><td><code>WAApi\/media<\/code><\/td><td>GET<\/td><td><code>mediaId<\/code><\/td><td><code>mediaList<\/code><\/td><\/tr><tr><td>Download media<\/td><td><code>WAApi\/media<\/code><\/td><td>GET<\/td><td><code>mediaId<\/code>, <code>download<\/code><\/td><td><code>mediaList<\/code> or the binary<\/td><\/tr><tr><td>Delete media<\/td><td><code>WAApi\/media<\/code><\/td><td>DELETE<\/td><td><code>mediaId<\/code><\/td><td><code>mediaId<\/code><\/td><\/tr><tr><td>Create template<\/td><td><code>WAApi\/template<\/code><\/td><td>POST<\/td><td><code>wabaNumber<\/code>, <code>templateName<\/code>, <code>category<\/code>, <code>body<\/code>, <code>language<\/code>, <code>msgType<\/code><\/td><td><code>templateName<\/code><\/td><\/tr><tr><td>Read templates<\/td><td><code>WAApi\/template<\/code><\/td><td>GET<\/td><td><code>wabaNumber<\/code>, optional <code>templateName<\/code><\/td><td><code>templateList<\/code><\/td><\/tr><tr><td>Delete template<\/td><td><code>WAApi\/template<\/code><\/td><td>DELETE<\/td><td><code>wabaNumber<\/code>, <code>templateName<\/code>, <code>language<\/code><\/td><td><code>templateName<\/code><\/td><\/tr><tr><td>Delivery report<\/td><td><code>WAApi\/report<\/code><\/td><td>POST<\/td><td><code>wabaNumber<\/code>, <code>fromDate<\/code>, <code>toDate<\/code>, <code>pageLimit<\/code><\/td><td><code>data.records<\/code><\/td><\/tr><tr><td>Inbox<\/td><td><code>rest\/wa\/v1\/inbox<\/code><\/td><td>GET<\/td><td><code>output<\/code>, optional filters<\/td><td><code>inboxList<\/code><\/td><\/tr><tr><td>Analytics<\/td><td><code>rest\/wa\/v1\/analytics<\/code><\/td><td>GET<\/td><td><code>output<\/code>, <code>action<\/code>, <code>start<\/code>, <code>end<\/code><\/td><td><code>analyticsList<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Twelve rows for ten operations, because <code>media<\/code> and <code>template<\/code> each collapse several operations onto one URL. That collapsing is the design decision that most often trips people who have integrated the SMS side of this platform first, where every operation has its own path segment such as <code>group\/create<\/code> and <code>group\/delete<\/code>. On WhatsApp there is no <code>\/create<\/code> or <code>\/delete<\/code> segment to look for. If you send a GET where you meant a DELETE, you will get a successful read back and conclude that the delete silently failed.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"send-contract\" class=\"wp-block-heading\">The Send Contract and Why It Is the Outlier<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>POST https:\/\/unify.smsgateway.center\/WAApi\/send<\/code> takes <code>userid<\/code>, <code>msg<\/code>, <code>wabaNumber<\/code>, <code>mobile<\/code>, <code>sendMethod<\/code> as one of <code>quick<\/code>, <code>group<\/code> or <code>file<\/code>, <code>msgType<\/code> as <code>text<\/code> or <code>media<\/code>, <code>templateName<\/code> and <code>output<\/code>. It responds with this shape, documented on <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-whatsapp-business-message\/\">the send page<\/a>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"messageId\": \"1234567890\",\n    \"mobile\": \"919999999999\",\n    \"statusCode\": 200,\n    \"description\": \"Message sent successfully\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in that block matter more than they look.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, <code>statusCode<\/code> is <code>200<\/code> with no quotation marks. Everywhere else in the WhatsApp family, and on almost every endpoint on the wider platform, that field is the string <code>\"200\"<\/code>. If your client models it as a string, deserialisation of this one response fails. If it models it as an integer, the other nine responses fail. Model it as a raw JSON value, or do not model it at all and branch on <code>status<\/code> instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, the human readable field is called <code>description<\/code>. On SMS and RCS the same concept is <code>reason<\/code>. On Telegram it is <code>message<\/code>. Four channels, four names for one idea, which is precisely why a shared <code>getErrorText(response)<\/code> helper written against one channel returns null on the others.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Third, the identifier is <code>messageId<\/code>, not <code>transactionId<\/code> and not <code>uuId<\/code>. The delivery report, which is the only place you will later learn what happened to this message, keys its rows on <code>uuId<\/code> and <code>msgId<\/code>. Persist what you receive here, then reconcile against the report rows by the recipient number and the submit timestamp until you have confirmed empirically which field lines up with which. The section on the delivery row below sets out the composite key that stays safe regardless.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A fourth point applies to India specifically. WhatsApp template messages are not governed by the DLT regime that constrains SMS in India, but the commercial rules around opt-in and template categorisation are set by Meta and are worth reading directly at <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/templates\/overview\" target=\"_blank\" rel=\"noopener nofollow\">Meta&#8217;s template fundamentals documentation<\/a>. The SMS side of your stack still needs DLT, and the <a href=\"https:\/\/trai.gov.in\/tcccpr\" target=\"_blank\" rel=\"noopener nofollow\">TRAI commercial communications regulations<\/a> remain the governing instrument there.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"five-envelopes\" class=\"wp-block-heading\">Five Response Envelopes in One Family<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is every envelope this family can return, side by side. Write one adapter per envelope and route by the operation you invoked, never by inspecting the response to guess which one you received.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Envelope A, the send response.<\/strong> Flat, no payload key, <code>statusCode<\/code> an unquoted integer, text in <code>description<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Envelope B, the media responses.<\/strong> <code>status<\/code>, then either <code>mediaId<\/code> and <code>mediaUrl<\/code> for a write, or <code>mediaList<\/code> for a read, then <code>statusCode<\/code> as a quoted <code>\"200\"<\/code> and <code>reason<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Envelope C, the template responses.<\/strong> <code>status<\/code>, then either <code>templateName<\/code> for a write or <code>templateList<\/code> for a read, then quoted <code>statusCode<\/code> and <code>reason<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Envelope D, the delivery report.<\/strong> Nested two levels deep and named unlike anything else on the platform:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"apiName\": \"report\",\n    \"action\": \"waReport\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": 200,\n    \"data\": {\n        \"cursors\": { \"next\": 19382015, \"start\": 18366779 },\n        \"pages\": { \"current\": 1, \"last\": 1 },\n        \"records\": &#91; ],\n        \"counts\": { \"total\": 8, \"current\": 6 }\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Compare that against the SMS delivery report envelope and you will see the same six concepts with every single name and type changed. SMS calls the API name <code>api<\/code>; here it is <code>apiName<\/code>. SMS calls the count block <code>count<\/code>; here it is <code>counts<\/code>, plural, and it sits inside <code>data<\/code> rather than at the top. SMS names the row array <code>reports_dlrList<\/code> and puts it at the top level; here it is <code>data.records<\/code>. SMS returns <code>code<\/code> as the string <code>\"200\"<\/code>; here it is the integer <code>200<\/code>. SMS cursors are opaque hexadecimal strings; here they are integers, and the documentation tells you to pass <code>1<\/code> for the first page. Nothing about a working SMS report ingester survives the move to WhatsApp except the general shape of the loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Envelope E, the REST generation.<\/strong> <code>status<\/code>, the named list key, <code>statusCode<\/code> as a quoted <code>\"200\"<\/code>, <code>reason<\/code>, then <code>totalRecords<\/code>, <code>page<\/code>, <code>limit<\/code>, and the echoed <code>fromDate<\/code> and <code>toDate<\/code> expanded to full timestamps. This is the same envelope the RCS and Telegram REST families use, so an adapter written for one of those three works here with only the list key changed.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"success-codes\" class=\"wp-block-heading\">The Success Code Tabulation<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Operation<\/th><th>Field name<\/th><th>Value on success<\/th><th>JSON type<\/th><\/tr><\/thead><tbody><tr><td><code>WAApi\/send<\/code><\/td><td><code>statusCode<\/code><\/td><td><code>200<\/code><\/td><td>unquoted integer<\/td><\/tr><tr><td><code>WAApi\/media<\/code> upload<\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>WAApi\/media<\/code> read<\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>WAApi\/media<\/code> delete<\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>WAApi\/template<\/code> create<\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>WAApi\/template<\/code> read<\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>WAApi\/template<\/code> delete<\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>WAApi\/report<\/code><\/td><td><code>code<\/code><\/td><td><code>200<\/code><\/td><td>unquoted integer<\/td><\/tr><tr><td><code>rest\/wa\/v1\/inbox<\/code><\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><tr><td><code>rest\/wa\/v1\/analytics<\/code><\/td><td><code>statusCode<\/code><\/td><td><code>\"200\"<\/code><\/td><td>quoted string<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two of the ten break the pattern, and they break it in two different ways: <code>send<\/code> keeps the field name but changes the type, <code>report<\/code> changes the field name and the type together. The safe branch, on this channel and on every other channel this platform offers, is the top level <code>status<\/code> string. It is <code>\"success\"<\/code> or <code>\"error\"<\/code> on all ten, it is always a string, and it is the only field you can test without knowing which operation produced the response.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"payload-type-flip\" class=\"wp-block-heading\">The Error Payload That Changes Type<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the trap that costs the most debugging time, and it is worth understanding rather than merely working around.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The inbox returns its rows as an array, so its error response is unsurprising:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"error\",\n  \"inboxList\": &#91;],\n  \"statusCode\": \"403\",\n  \"reason\": \"WhatsApp product is not enabled for this account.\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Analytics is the interesting one. On success <code>analyticsList<\/code> is an object, and the documentation states so explicitly: its shape depends on the action you selected. On error, that same key comes back as an empty array:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"error\",\n  \"analyticsList\": &#91;],\n  \"statusCode\": \"400\",\n  \"reason\": \"Date range cannot exceed 365 days.\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For a dynamically typed client in PHP, Python or JavaScript this is a nuisance. For a statically typed client in Go, Rust, Java or C# it is a production incident, because the type of <code>analyticsList<\/code> differs between the two branches. Your struct binds cleanly against the success case in every test you write, then throws a JSON type error in production the first time a range validation fails. What the operator sees in the log is a deserialisation stack trace, not the sentence &#8220;Date range cannot exceed 365 days&#8221;, and the actual cause is invisible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is a two stage parse, and it is the shape every client on this platform should adopt:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Stage one: parse loosely and read only the top level status.\nJsonNode envelope = mapper.readTree(rawBody);\nif (!\"success\".equals(envelope.path(\"status\").asText())) {\n    throw new WhatsAppApiException(\n        envelope.path(\"statusCode\").asText(),\n        envelope.path(\"reason\").asText());\n}\n\/\/ Stage two: only now bind the typed payload.\nAnalyticsSummary payload =\n    mapper.treeToValue(envelope.get(\"analyticsList\"), AnalyticsSummary.class);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three lines of discipline, and the failure message the operator reads is the one the API actually sent. The same pattern belongs in the Spring Boot client described in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-java-spring-boot-integration\/\">Messaging in Java and Spring Boot<\/a>, and it is a strictly better default than configuring the deserialiser to be permissive, because permissiveness hides the next schema change too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A related discipline applies to fields you did not ask for. Analytics returns <code>dateLabel<\/code> alongside <code>date<\/code>, and the delivery grouping rows return a <code>label<\/code> that duplicates <code>billingModel<\/code>. Bind only the fields you actually use, configure your deserialiser to ignore unknown properties, and never build a feature on a field whose purpose you cannot state in one sentence. Presentation helpers in a response are a convenience for a dashboard, not a contract you should depend on.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"media-lifecycle\" class=\"wp-block-heading\">The Media Lifecycle, Start to Finish<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WhatsApp is the only channel on this platform with a first class media library, and it has no analogue on SMS, RCS or Telegram. The lifecycle is four operations on one URL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Upload.<\/strong> POST to <code>WAApi\/media<\/code> as multipart, with <code>wabaNumber<\/code>, <code>mediaType<\/code> as one of <code>image<\/code>, <code>video<\/code> or <code>document<\/code>, an <code>identifier<\/code> you choose, a <code>description<\/code>, and the file itself. The response gives you back both an identifier and a URL:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"mediaId\": \"2054629440705480684\",\n    \"mediaUrl\": \"https:\/\/unify.smsgateway.center\/media\/471mxxxxx0705480684.jpg\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that <code>mediaId<\/code> is nineteen digits and arrives quoted here, which is the correct treatment. Store it as text and keep it that way, because the delivery report treats long numerics differently and you do not want two representations of the same class of value in one schema. The reasoning is set out in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">The Outbound Message Table<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Read.<\/strong> GET the same URL with no extra parameters for the whole library, or with <code>mediaId<\/code> for one item. Both return the identical envelope, and both put the rows in <code>mediaList<\/code>. Here is the part that will break a naive client:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n   \"status\":\"success\",\n   \"mediaList\":\"&#91;{\\\"identifier\\\":\\\"PDF\\\",\\\"creationTime\\\":1681543055938,\\\"description\\\":\\\"pdf\\\",\\\"mediaType\\\":\\\"document\\\",\\\"mediaId\\\":\\\"3816394210554235591\\\",\\\"wabaNumber\\\":917039670396,\\\"status\\\":\\\"active\\\"}]\",\n   \"statusCode\":\"200\",\n   \"reason\":\"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>mediaList<\/code> is a string, not an array. It holds a JSON document that has been encoded a second time. You parse the envelope, read <code>mediaList<\/code> as a string, and parse that string again to get your rows. Double encoding also appears on the RCS template <code>data<\/code> field and on the RCS delivery <code>message<\/code> field, so if you already handle RCS you have a helper for this. If you do not, write one now and name it clearly, because the failure mode when you forget is a type error several frames away from the cause.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inside the decoded rows, <code>wabaNumber<\/code> is an unquoted number while <code>mediaId<\/code> is a quoted string, on the same row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Download.<\/strong> GET the same URL with <code>mediaId<\/code> and <code>download<\/code>. Setting <code>download=true<\/code> fetches the file. Setting <code>download=false<\/code> returns the metadata block shown above. Because one URL and one verb serve both behaviours, the flag is the only thing standing between your code and a binary body arriving where you expected JSON, so set it explicitly on every call rather than relying on a default.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Delete.<\/strong> DELETE the same URL with <code>mediaId<\/code>. The response is short and, importantly, final:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"mediaId\": \"7074855483357676350\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That <code>reason<\/code> of <code>\"success\"<\/code> is worth contrasting against template deletion below, which returns <code>\"Accepted for processing.\"<\/code> instead. Media deletion completes when the call returns. Template deletion does not. Two operations, same family, same verb, different completion semantics, distinguishable only by reading the <code>reason<\/code> string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One asymmetry to design around: upload requires <code>wabaNumber<\/code>, but read, download and delete do not. Writes are scoped to a WhatsApp Business Account number, reads are scoped to the account. If you operate several WABA numbers on one account, the library you read back is the union across all of them, and the row&#8217;s own <code>wabaNumber<\/code> field is how you tell them apart. Filter client side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The portal path to the same capability, for anyone on your team who would rather click than curl, is documented in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-media-management-images-videos-documents\/\">WhatsApp Business API Media Management<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"template-management\" class=\"wp-block-heading\">Template Management End to End<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>WAApi\/template<\/code> is one URL and three verbs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>POST creates.<\/strong> Required: <code>wabaNumber<\/code>, <code>msgType<\/code> as <code>text<\/code> or <code>media<\/code>, <code>category<\/code> as one of <code>UTILITY<\/code>, <code>MARKETING<\/code> or <code>AUTHENTICATION<\/code>, <code>body<\/code>, <code>templateName<\/code>, and <code>language<\/code> as a code such as <code>en<\/code>. Optional: <code>header<\/code> at up to 60 characters and permitting one variable, <code>headerSample<\/code>, <code>bodySample<\/code>, <code>footer<\/code> at up to 60 characters and permitting no variables at all, <code>templateDescription<\/code>, <code>buttons<\/code> as a JSON payload, and <code>mediaType<\/code> when <code>msgType<\/code> is <code>media<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>buttons<\/code> payload takes two documented shapes. Quick replies look like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&#91;\n  { \"text\": \"RESOLVED\", \"type\": \"QUICK_REPLY\" },\n  { \"text\": \"NO\",       \"type\": \"QUICK_REPLY\" },\n  { \"text\": \"YES\",      \"type\": \"QUICK_REPLY\" }\n]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Call to action buttons look like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&#91;\n  { \"phone_number\": \"+1(650) 555-1111\", \"text\": \"your-phone-button-text\", \"type\": \"PHONE_NUMBER\" },\n  { \"text\": \"your-url-button-text\", \"type\": \"URL\", \"url\": \"https:\/\/www.website.com\" }\n]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the key naming inside that payload. <code>phone_number<\/code> is snake case, while every parameter outside it is camel case. That is because the buttons array is passed through to Meta&#8217;s own template format rather than being translated, so build it to Meta&#8217;s rules, not to the surrounding platform&#8217;s conventions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Placeholders are <code>{{1}}<\/code>, <code>{{2}}<\/code> and <code>{{3}}<\/code>.<\/strong> Numbered, positional, wrapped in double curly braces. This is the fourth placeholder syntax on the platform, after DLT&#8217;s <code>{#var#}<\/code> on Indian SMS, RCS&#8217;s <code>[name]<\/code>, and Telegram&#8217;s single brace <code>{name}<\/code>. There is no shared substitution routine that can serve two of these correctly, and attempting one is how a marketing message goes out with a literal <code>{{1}}<\/code> in the body. Write four, name them for their channel, and unit test each against a value containing the delimiter characters of the other three.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both the <code>header<\/code> and the <code>body<\/code> accept variables. The <code>footer<\/code> does not. If you need a variable in the closing line, it belongs in the body.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>GET reads.<\/strong> Pass <code>wabaNumber<\/code> for the whole set, or <code>wabaNumber<\/code> plus <code>templateName<\/code> for one. Both calls return the same shape, and this matters: the single read still returns an array.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"templateList\": &#91;\n        {\n            \"template\": {\n                \"footer\": \"By XYZ Brand\",\n                \"header\": \"This is {{1}} header\",\n                \"body\": \"Your account {{1}} is having {{2}} on {{3}}\"\n            },\n            \"creationTime\": 1740835233326,\n            \"templateName\": \"test1\",\n            \"whatsAppStatus\": \"pending\",\n            \"systemStatus\": \"pending\",\n            \"mediaType\": \"text\",\n            \"language\": \"en\",\n            \"category\": \"UTILITY\"\n        }\n    ],\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The row is partially double wrapped, which is unusual. The message components sit inside a nested <code>template<\/code> object. The metadata, meaning <code>creationTime<\/code>, <code>templateName<\/code>, both status fields, <code>mediaType<\/code>, <code>language<\/code> and <code>category<\/code>, sits at the row level outside that wrapper. Elsewhere on this platform, when a row is double wrapped the whole row is inside the wrapper. Here it is split, so a generic unwrapper that returns <code>row.template<\/code> and stops will hand you the text and throw away the approval state.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One casing detail. Submit <code>templateName<\/code> as <code>Test1<\/code> and it returns as <code>test1<\/code>. Lower case the name in your own store at the point of creation, and always send the string you read back rather than the string you submitted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>DELETE removes.<\/strong> It needs <code>wabaNumber<\/code>, <code>templateName<\/code> and <code>language<\/code>. The presence of <code>language<\/code> in the delete key tells you something the create page does not: a template name is unique per language, not globally. If you maintain the same template in English and Hindi, you are maintaining two objects that share a name, and deleting one does not touch the other.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"templateName\": \"test1\",\n    \"statusCode\": \"200\",\n    \"reason\": \"Accepted for processing.\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Accepted, not deleted. Re-read the list to confirm the row is gone before you mark anything complete in your own system.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your team creates templates through the portal instead, the click path is written up in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/how-to-create-whatsapp-business-api-template\/\">How to Create a WhatsApp Business API Template<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-approval-states\" class=\"wp-block-heading\">Two Approval States, Not One<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>whatsAppStatus<\/code> and <code>systemStatus<\/code> are two independent fields on every template row, and in the documented sample both read <code>pending<\/code>. No other channel on this platform exposes a dual approval state. RCS templates carry a single integer <code>status<\/code>. SMS templates carry DLT identifiers issued elsewhere. Here there are two gates, and a template is usable only when both are satisfied.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Treat them as a compound state in your own schema. Store both columns. Make your send path read both. The failure you are avoiding is a template that Meta has approved but that has not yet cleared the platform side, which will read as approved to any client that checks only the field with WhatsApp in its name.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The polling loop that follows from this is straightforward:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def template_is_ready(session, waba, name):\n    r = session.get(\n        \"https:\/\/unify.smsgateway.center\/WAApi\/template\",\n        params={\"userid\": USERID, \"wabaNumber\": waba,\n                \"templateName\": name, \"output\": \"json\"},\n        headers={\"apikey\": API_KEY},\n        timeout=20,\n    )\n    env = r.json()\n    if env.get(\"status\") != \"success\":\n        raise RuntimeError(env.get(\"reason\", \"unknown\"))\n    rows = env.get(\"templateList\") or &#91;]\n    if not rows:\n        return False                      # single read still returns an array\n    row = rows&#91;0]\n    return (row.get(\"whatsAppStatus\") == \"approved\"\n            and row.get(\"systemStatus\") == \"approved\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things in that function are deliberate. The empty list check exists because a single template read returns an array, so a missing template gives you <code>[]<\/code> rather than a null row. And the function requires both states rather than either, because a partial approval is not an approval.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The exact string values that indicate an approved state are best read from your own account rather than assumed. Create one template, poll this function every thirty seconds while watching the raw values, and record what you observe. That takes an afternoon and produces a constant you can trust for the lifetime of the integration.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"delivery-row-four-ways\" class=\"wp-block-heading\">The Delivery Row Compared Four Ways<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>POST https:\/\/unify.smsgateway.center\/WAApi\/report<\/code> takes <code>wabaNumber<\/code>, <code>fromDate<\/code> and <code>toDate<\/code> in <code>YYYY-MM-DD HH:MM:SS<\/code> form, <code>pageLimit<\/code>, <code>output<\/code>, and optionally <code>mobileNo<\/code> and <code>startCursor<\/code>. Full second granularity on the date range is a genuine advantage: the SMS report accepts date granularity only, so a WhatsApp reconciler can pull a precise window rather than a whole day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a documented row verbatim:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"msgType\": \"text\",\n    \"deliveryTime\": 1691165382000,\n    \"billingModel\": \"SIC\",\n    \"msgId\": \"tnNwDCgP1WhfLwn\",\n    \"cause\": \"Read By User\",\n    \"readTime\": 1691165389000,\n    \"mobileNo\": 919xxxxxxxxx,\n    \"uuId\": 3917313917152021246,\n    \"wabaNumber\": 9170396xxxxx,\n    \"globalErrorCode\": \"8006\",\n    \"cursorId\": 18366779,\n    \"submitTime\": \"1691165380316\",\n    \"status\": \"DELIVERED\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read the types before the names. <code>submitTime<\/code> is a quoted string. <code>deliveryTime<\/code> and <code>readTime<\/code>, on the same row, describing the same class of value, are unquoted integers. <code>globalErrorCode<\/code> is a quoted string here, where the same field is an unquoted integer on SMS, RCS and Telegram. <code>mobileNo<\/code>, <code>uuId<\/code> and <code>wabaNumber<\/code> are all unquoted numbers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The row also shows a subtlety in the status model. <code>status<\/code> reads <code>DELIVERED<\/code> while <code>cause<\/code> reads <code>Read By User<\/code> and <code>readTime<\/code> is populated. So read is not a terminal value of <code>status<\/code>; it is signalled by <code>readTime<\/code> being non zero. If you are counting reads, count populated <code>readTime<\/code> values, not <code>status<\/code> strings.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>billingModel<\/code> carries a code describing which commercial category the message was billed under. Store it verbatim as text and group by it when reconciling, because it is the join key between your message log and your invoice. Do not attempt to expand the abbreviation in your own reporting; the code as sent is what will match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the four way comparison, which is the part you cannot get anywhere else.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Concept<\/th><th>SMS<\/th><th>RCS<\/th><th>Telegram<\/th><th>WhatsApp<\/th><\/tr><\/thead><tbody><tr><td>Endpoint<\/td><td><code>SMSApi\/reports\/status<\/code><\/td><td><code>rest\/rcs\/v1\/dlr<\/code><\/td><td><code>rest\/tg\/v1\/dlr<\/code><\/td><td><code>WAApi\/report<\/code><\/td><\/tr><tr><td>HTTP method<\/td><td>GET or POST<\/td><td>GET<\/td><td>GET<\/td><td>POST only<\/td><\/tr><tr><td>Paging model<\/td><td>opaque hex cursor<\/td><td><code>page<\/code> and <code>limit<\/code><\/td><td><code>page<\/code> and <code>limit<\/code><\/td><td>integer cursor<\/td><\/tr><tr><td>Row array key<\/td><td><code>reports_dlrList<\/code><\/td><td><code>rcsDlrList<\/code> style list key<\/td><td><code>dlrList<\/code> style list key<\/td><td><code>data.records<\/code><\/td><\/tr><tr><td>Delivery outcome field<\/td><td><code>status<\/code><\/td><td><code>deliveryStatus<\/code><\/td><td><code>deliveryStatus<\/code><\/td><td><code>status<\/code><\/td><\/tr><tr><td>Outcome casing<\/td><td>upper case<\/td><td>upper case<\/td><td>lower case<\/td><td>upper case<\/td><\/tr><tr><td>Platform identifier<\/td><td><code>uuId<\/code> quoted string<\/td><td><code>uuId<\/code> quoted string<\/td><td><code>uuId<\/code> quoted string<\/td><td><code>uuId<\/code> unquoted number<\/td><\/tr><tr><td>Provider identifier<\/td><td><code>msgId<\/code>, not unique<\/td><td><code>msgId<\/code> opaque string<\/td><td><code>msgId<\/code> short string<\/td><td><code>msgId<\/code> opaque string<\/td><\/tr><tr><td>Recipient field<\/td><td><code>mobileNo<\/code> unquoted<\/td><td><code>mobileNo<\/code> quoted<\/td><td><code>chatId<\/code> quoted<\/td><td><code>mobileNo<\/code> unquoted<\/td><\/tr><tr><td>Cost field<\/td><td><code>amount<\/code> float<\/td><td><code>amount<\/code> string, 4dp<\/td><td><code>charges<\/code> string, 2dp<\/td><td>see analytics and inbox<\/td><\/tr><tr><td>Error code type<\/td><td>integer<\/td><td>integer<\/td><td>integer, binary<\/td><td>quoted string<\/td><\/tr><tr><td>Free text cause<\/td><td><code>cause<\/code><\/td><td><code>cause<\/code><\/td><td><code>vendorErrorCode<\/code><\/td><td><code>cause<\/code><\/td><\/tr><tr><td>Read receipt<\/td><td>none<\/td><td><code>readTime<\/code><\/td><td>none<\/td><td><code>readTime<\/code><\/td><\/tr><tr><td>Click signal<\/td><td>none<\/td><td><code>clickedTime<\/code><\/td><td>none<\/td><td>none<\/td><\/tr><tr><td>Explicit terminality flag<\/td><td>none<\/td><td>none<\/td><td><code>isFinal<\/code><\/td><td>none<\/td><\/tr><tr><td>Date granularity<\/td><td>date only<\/td><td>full timestamp<\/td><td>full timestamp<\/td><td>full timestamp<\/td><\/tr><tr><td>Billing category on the row<\/td><td>none<\/td><td>none<\/td><td>none<\/td><td><code>billingModel<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Fifteen rows of difference across four channels, and not one column is a superset of another. This is the empirical basis for the rule that ends the diagram: never share a row deserialiser between two channels on this platform. Write four narrow mappers that each produce the same internal <code>delivery_event<\/code> record, and let the differences live in the mappers rather than leaking into your domain model. The design for that shared record is worked through in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">Observability for Messaging Pipelines<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-whatsapp-identifier-chain.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-whatsapp-identifier-chain.svg\" alt=\"Diagram of the WhatsApp identifier chain from send to delivery report to inbox, with a four way field comparison against SMS, RCS and Telegram.\" class=\"wp-image-2969\"\/><\/a><figcaption class=\"wp-element-caption\">One value, several names, several JSON types. The nineteen digit unquoted identifier is the one that silently corrupts.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"nineteen-digit-number\" class=\"wp-block-heading\">The Nineteen Digit Number That Breaks JavaScript<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>\"uuId\": 3917313917152021246<\/code> is nineteen digits and it is not quoted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">JavaScript&#8217;s <code>Number.MAX_SAFE_INTEGER<\/code> is 9007199254740991, sixteen digits. Anything larger cannot be represented exactly as a double. <code>JSON.parse<\/code> does not warn you. It returns a number that looks plausible, differs from the original in its last few digits, and reconciles against nothing. The same applies to <code>mobileNo<\/code> and <code>wabaNumber<\/code> on this row, and to <code>cursorId<\/code>, though that last one is small enough today to be safe by luck rather than design.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are two correct responses and one incorrect one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The incorrect response is to parse normally and convert to a string afterwards. By then the damage is done.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first correct response is to rewrite the raw body before parsing, quoting long numeric literals so they arrive as strings:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const raw = await response.text();\n\n\/\/ Quote any bare integer of 16 or more digits before parsing.\nconst safe = raw.replace(\/:\\s*(\\d{16,})(?=\\s*&#91;,}])\/g, ': \"$1\"');\nconst body = JSON.parse(safe);\n\n\/\/ body.data.records&#91;0].uuId is now the exact string \"3917313917152021246\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That regular expression is deliberately anchored on the colon and the following delimiter so it cannot touch digits inside a quoted string. Test it against a row where the message text itself contains a long number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second correct response, available in newer runtimes, is a reviver that intercepts the source text of each value before it becomes a number. Whichever you choose, apply it at the transport layer so no caller can forget.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And in every language, store identifiers as <code>TEXT<\/code> or <code>VARCHAR<\/code> in the database, never as <code>BIGINT<\/code>. Nineteen digits fits in a signed 64 bit integer, so the database will accept it, and you will have introduced a silent conversion at the boundary for no benefit. The identifiers are opaque tokens. You never do arithmetic on them. The same warning applies to SMS and RCS transaction identifiers, which are eighteen or nineteen digits, and it is covered in the Node.js context in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">the Node.js integration tutorial<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbox-and-charges\" class=\"wp-block-heading\">The Inbox, and What It Costs You<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>GET https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/inbox<\/code> is read only. It filters on <code>wabaNumber<\/code>, <code>mobileNo<\/code>, <code>isReplied<\/code> as <code>0<\/code> or <code>1<\/code>, <code>agentId<\/code>, <code>waMsgId<\/code> up to 50 characters, and a <code>fromDate<\/code> and <code>toDate<\/code> range that accepts either a bare date or a full timestamp, with <code>from<\/code> and <code>to<\/code> as aliases. Paging is <code>page<\/code> and <code>limit<\/code>, default 50, maximum 200. Results come back newest first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A 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 observations.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>charges<\/code> is present on an inbound row. Neither the RCS inbox nor the Telegram inbox carries a cost field. Inbound WhatsApp traffic is a billable line item on this platform, which means your reconciliation job needs to sum inbox charges as well as outbound ones, and your capacity planning needs to account for reply volume rather than treating it as free. Sum <code>charges<\/code> by date and compare against your invoice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>waMsgId<\/code> is Meta&#8217;s own identifier, prefixed <code>wamid.<\/code>. It is a different namespace from the <code>msgId<\/code> on a delivery row and a different namespace again from the <code>messageId<\/code> your send returned. Nothing on the inbox row links back to an outbound <code>uuId<\/code>. If you need to thread a reply to the message that prompted it, correlate on <code>mobileNo<\/code> plus a time window, and keep the window narrow enough that two conversations with the same person cannot collide.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>agentName<\/code> is the string <code>\"NA\"<\/code> when no agent is assigned, not an empty string and not null. Test for the literal, or normalise it at the mapper, because a truthiness check will treat <code>\"NA\"<\/code> as a real assignment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>message<\/code> is plain text. On the RCS inbox the equivalent field is double encoded JSON. Same concept, two representations, one more reason the mappers stay separate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Unlike SMS, this channel has no outbound push. The SMS side of the platform can push delivery receipts to a webhook, a mechanism covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">Real-Time DLR Webhooks<\/a> and in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-webhooks-real-time-integration-guide\/\">WhatsApp webhooks integration guide<\/a>. For the endpoints documented here, both delivery status and inbound messages are retrieved by polling. Size your poller accordingly: a loop on the inbox every thirty to sixty seconds with <code>limit=200<\/code> and a high water mark on <code>incomingId<\/code> keeps latency acceptable without hammering the endpoint.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"analytics-denominator\" class=\"wp-block-heading\">Analytics and the Right Denominator<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>GET https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/analytics<\/code> is GET only, and the documentation states that any other method returns 405. It takes an <code>action<\/code> of <code>summary<\/code>, <code>trend<\/code> or <code>delivery<\/code>, a <code>start<\/code> and <code>end<\/code> in <code>YYYY-MM-DD<\/code> form with a maximum range of 365 days, an optional <code>wabaNumber<\/code>, a <code>reptype<\/code> of <code>day<\/code> or <code>month<\/code> for trend, and a <code>groupBy<\/code> of <code>billable<\/code>, <code>date<\/code>, <code>waNumber<\/code> or <code>country<\/code> for delivery.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The 365 day cap is the widest on the platform by a factor of nearly twelve. The SMS dashboard and the Telegram analytics endpoint both cap at 31 days. If you are building a year over year view, WhatsApp is the one channel where you do not have to stitch twelve calls together.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>action=summary<\/code> returns six labelled metrics:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"metrics\": &#91;\n  { \"label\": \"Submitted\", \"value\": 1250 },\n  { \"label\": \"Delivered\", \"value\": 980 },\n  { \"label\": \"Read\",      \"value\": 640 },\n  { \"label\": \"Failed\",    \"value\": 45  },\n  { \"label\": \"Pending\",   \"value\": 120 },\n  { \"label\": \"Rejected\",  \"value\": 15  }\n]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Submitted<\/code> is the number you want as the denominator of a delivery rate. The reason is a failure mode worth stating plainly: if you compute delivered over delivered plus failed, and your delivery reports stop arriving, both terms freeze and the ratio holds steady at a healthy looking value while nothing at all is being delivered. Submitted is the only denominator that moves the instant sending continues and confirmation stops. Here, submitted is handed to you directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note also that Submitted at 1250 is larger than Delivered plus Read plus Failed plus Pending plus Rejected, which sum to 1800. That is because Read is a subset of Delivered rather than a peer of it. Summing the six labels double counts. Chart them as a funnel, not as a pie.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>action=delivery<\/code> returns rows with <code>requested<\/code>, <code>delivered<\/code>, <code>read<\/code>, <code>failed<\/code>, <code>notSent<\/code> and <code>amount<\/code>, grouped by whichever <code>groupBy<\/code> you passed. <code>amount<\/code> here is an unquoted float, <code>45.5<\/code>. On the inbox, cost is <code>charges<\/code>, a quoted string. So cost has two names and two JSON types within the WhatsApp family alone, and the discipline of never sharing a money parser applies within a channel, not only between channels. Parse both through a decimal type, never a float, before any arithmetic touches them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"first-send\" class=\"wp-block-heading\">Your First Send in Six Steps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Generate an API key and note your <code>wabaNumber<\/code>.<\/strong> The key goes in the <code>apikey<\/code> header on every call. The WABA number is the approved WhatsApp Business Account number you send from, in international format with no plus sign.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Create a template and record its name in lower case.<\/strong> POST to <code>WAApi\/template<\/code> with <code>category<\/code>, <code>body<\/code>, <code>templateName<\/code>, <code>language<\/code> and <code>msgType<\/code>. Use <code>{{1}}<\/code> style placeholders and supply a <code>bodySample<\/code> with realistic values, because the sample is what Meta reviews.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Poll until both approval states are satisfied.<\/strong> GET the same URL with your <code>templateName<\/code>. Read <code>whatsAppStatus<\/code> and <code>systemStatus<\/code> from the row and require both. Do not send until both are clear.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Upload any media the template header needs.<\/strong> POST the file to <code>WAApi\/media<\/code> with <code>wabaNumber<\/code>, <code>mediaType<\/code>, <code>identifier<\/code> and <code>description<\/code>. Store the returned <code>mediaId<\/code> as text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Send one message to a number you control.<\/strong> POST to <code>WAApi\/send<\/code> with <code>wabaNumber<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>sendMethod=quick<\/code>, <code>msgType<\/code>, <code>templateName<\/code> and <code>output=json<\/code>. Persist <code>messageId<\/code>, the recipient, and your own submit timestamp before you do anything else with the response.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Pull the delivery report for a narrow window and match the row.<\/strong> POST to <code>WAApi\/report<\/code> with <code>wabaNumber<\/code>, a <code>fromDate<\/code> and <code>toDate<\/code> bracketing your send by a few minutes, and <code>pageLimit=20<\/code>. Apply the long number protection before parsing. Compare the returned <code>uuId<\/code>, <code>msgId<\/code>, <code>mobileNo<\/code> and <code>submitTime<\/code> against what you stored. That single comparison, done once by hand, tells you exactly which identifier reconciles against which, and it is worth more than any amount of reading.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-languages\" class=\"wp-block-heading\">Four Languages, Four Traps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Four short samples, each demonstrating a different failure mode rather than repeating the same request.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">cURL: the multipart upload, with the header auth<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>curl --location --request POST 'https:\/\/unify.smsgateway.center\/WAApi\/media' \\\n  --header 'apikey: YourApiKey' \\\n  --form 'userid=\"YourUsername\"' \\\n  --form 'wabaNumber=\"9170396xxxxx\"' \\\n  --form 'output=\"json\"' \\\n  --form 'mediaType=\"image\"' \\\n  --form 'identifier=\"SpringSaleBanner\"' \\\n  --form 'description=\"Spring sale header image\"' \\\n  --form 'mediaFile=@\"\/path\/to\/banner.jpg\"'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: the file part is <code>mediaFile<\/code>, and it is the one parameter you will not find in the parameter table on the upload page. It appears only in the sample code. Sample code on this platform is a primary source and deserves the same reading as the table above it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">JavaScript: the identifier that arrives already corrupted<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>const res = await fetch('https:\/\/unify.smsgateway.center\/WAApi\/report', {\n  method: 'POST',\n  headers: { apikey: process.env.SGC_API_KEY },\n  body: new URLSearchParams({\n    userid: process.env.SGC_USERID,\n    wabaNumber: '9170396xxxxx',\n    fromDate: '2026-09-16 00:00:00',\n    toDate:   '2026-09-16 23:59:59',\n    pageLimit: '100',\n    output: 'json',\n  }),\n});\n\nconst raw  = await res.text();\nconst safe = raw.replace(\/:\\s*(\\d{16,})(?=\\s*&#91;,}])\/g, ': \"$1\"');\nconst body = JSON.parse(safe);\n\nif (body.status !== 'success') {\n  throw new Error(`${body.code}: ${body.msg}`);\n}\nfor (const row of body.data.records) {\n  \/\/ row.uuId is an exact string. row.submitTime is already a string.\n  \/\/ row.deliveryTime is a number. Normalise before storing.\n  await store({\n    platformId:  row.uuId,\n    providerId:  row.msgId,\n    recipient:   String(row.mobileNo),\n    submittedAt: Number(row.submitTime),\n    deliveredAt: row.deliveryTime || null,\n    readAt:      row.readTime || null,\n    outcome:     row.status,\n    errorCode:   row.globalErrorCode,\n    billing:     row.billingModel,\n  });\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: <code>String(row.mobileNo)<\/code> runs after the rewrite, not instead of it. Converting a corrupted number to a string preserves the corruption perfectly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: the double encoded media list<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import json, requests\n\nr = requests.get(\n    \"https:\/\/unify.smsgateway.center\/WAApi\/media\",\n    params={\"userid\": USERID, \"output\": \"json\"},\n    headers={\"apikey\": API_KEY},\n    timeout=20,\n)\nenv = r.json()\nif env.get(\"status\") != \"success\":\n    raise RuntimeError(f'{env.get(\"statusCode\")}: {env.get(\"reason\")}')\n\n# mediaList is a STRING containing JSON. Parse it a second time.\nitems = json.loads(env&#91;\"mediaList\"]) if env.get(\"mediaList\") else &#91;]\nfor item in items:\n    print(item&#91;\"mediaId\"], item&#91;\"mediaType\"], item&#91;\"identifier\"], item&#91;\"status\"])<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: <code>env[\"mediaList\"]<\/code> looks like a list in every log line you will read, because a JSON string of an array prints with square brackets. It is a string. Iterating it gives you characters.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Java: the payload that changes type on failure<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>String raw = client.get(ANALYTICS_URL, params);      \/\/ returns the body as text\nJsonNode env = mapper.readTree(raw);\n\nif (!\"success\".equals(env.path(\"status\").asText())) {\n    \/\/ analyticsList is &#91;] here. Do not touch it.\n    throw new WhatsAppApiException(\n        env.path(\"statusCode\").asText(),\n        env.path(\"reason\").asText());\n}\n\n\/\/ Only now is analyticsList guaranteed to be an object.\nAnalyticsSummary summary =\n    mapper.treeToValue(env.get(\"analyticsList\"), AnalyticsSummary.class);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The trap: binding the whole envelope to a typed class in one step passes every test you write against a success fixture, then throws on the first 400 in production, and the exception the operator reads is about JSON types rather than about the date range.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a WhatsApp Postman collection available from the downloads section of the developer documentation, which is the fastest way to confirm a request shape before you write any of the above. There is no official Java, Node.js, Go or .NET SDK for this platform; the PHP and Python SDKs cover the SMS surface.<\/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 Real Money<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Branching on <code>statusCode<\/code>.<\/strong> Two of ten operations do not return it in the form you expect. Branch on <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Sharing a deserialiser with another channel.<\/strong> Fifteen fields differ across the four delivery rows. A shared mapper produces wrong data rather than an error, which is worse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Parsing the report body with a plain <code>JSON.parse<\/code>.<\/strong> Nineteen digit identifiers corrupt silently and reconcile against nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Treating template creation as complete when the call returns.<\/strong> It returns <code>\"Accepted for processing.\"<\/code> Poll until both approval states clear.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Checking only <code>whatsAppStatus<\/code>.<\/strong> There are two gates. A template approved by one and not the other will fail at send time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Iterating <code>mediaList<\/code> directly.<\/strong> It is a string containing JSON. Parse it twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Sending a GET where you meant a DELETE.<\/strong> On <code>media<\/code> and <code>template<\/code> the method is the operation. A misdirected GET returns 200 and changes nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Reusing a placeholder substitution routine across channels.<\/strong> WhatsApp is <code>{{1}}<\/code>, Telegram is <code>{name}<\/code>, RCS is <code>[name]<\/code>, Indian SMS is <code>{#var#}<\/code>. Four routines.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Ignoring inbox charges.<\/strong> Inbound rows carry a cost. If your reconciliation only sums outbound, your figures will drift from your invoice by exactly the volume of replies you receive.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Summing the six analytics metrics.<\/strong> Read is a subset of Delivered. The total is not the sum.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Choosing WhatsApp Over SMS, RCS or Telegram<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Dimension<\/th><th>SMS<\/th><th>RCS<\/th><th>Telegram<\/th><th>WhatsApp<\/th><\/tr><\/thead><tbody><tr><td>Addressing<\/td><td>phone number<\/td><td>phone number<\/td><td>chat ID from opt-in<\/td><td>phone number<\/td><\/tr><tr><td>Recipient must opt in first<\/td><td>no<\/td><td>no<\/td><td>yes<\/td><td>yes, per Meta policy<\/td><\/tr><tr><td>Template approval required<\/td><td>yes in India, via DLT<\/td><td>yes, platform side<\/td><td>no<\/td><td>yes, Meta plus platform<\/td><\/tr><tr><td>Approval states to track<\/td><td>DLT identifiers<\/td><td>one integer<\/td><td>none<\/td><td>two independent fields<\/td><\/tr><tr><td>Placeholder syntax<\/td><td><code>{#var#}<\/code><\/td><td><code>[name]<\/code><\/td><td><code>{name}<\/code><\/td><td><code>{{1}}<\/code><\/td><\/tr><tr><td>Media library<\/td><td>no<\/td><td>no<\/td><td>no<\/td><td>yes, four operations<\/td><\/tr><tr><td>Rich buttons<\/td><td>no<\/td><td>yes, in template<\/td><td>yes, <code>replyMarkup<\/code><\/td><td>yes, in template<\/td><\/tr><tr><td>Read receipts<\/td><td>no<\/td><td>yes<\/td><td>no<\/td><td>yes<\/td><\/tr><tr><td>Click tracking<\/td><td>via smart links<\/td><td><code>clickedTime<\/code><\/td><td>no<\/td><td>no<\/td><\/tr><tr><td>Inbound available<\/td><td>yes, shortcode<\/td><td>yes, polling<\/td><td>yes, polling<\/td><td>yes, polling<\/td><\/tr><tr><td>Inbound billable<\/td><td>no<\/td><td>no<\/td><td>no<\/td><td>yes, <code>charges<\/code> on the row<\/td><\/tr><tr><td>Outbound delivery push<\/td><td>yes, one webhook<\/td><td>no<\/td><td>no<\/td><td>no<\/td><\/tr><tr><td>Agent assignment exposed<\/td><td>no<\/td><td>no<\/td><td>seat count only<\/td><td><code>agentId<\/code> and <code>agentName<\/code><\/td><\/tr><tr><td>Analytics range cap<\/td><td>31 days<\/td><td>not stated<\/td><td>31 days<\/td><td>365 days<\/td><\/tr><tr><td>Report date granularity<\/td><td>date only<\/td><td>full timestamp<\/td><td>full timestamp<\/td><td>full timestamp<\/td><\/tr><tr><td>Billing category on the row<\/td><td>no<\/td><td>no<\/td><td>no<\/td><td>yes, <code>billingModel<\/code><\/td><\/tr><tr><td>Scheduling parameter<\/td><td>yes<\/td><td>no<\/td><td>no<\/td><td>yes<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Reading that table as a decision: choose WhatsApp when you need rich media with a media library behind it, when read receipts matter to the business case, when you have a shared agent inbox and want the agent attribution in your data, and when your reporting needs a full year in one call. Choose SMS when reach matters more than richness, when you cannot rely on opt in, or when you need a delivery push rather than a poll. Choose RCS when you want click attribution. Choose Telegram when your audience already lives there and the per message cost matters more than reach.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In practice most production systems use more than one and fall back between them. That configuration is a platform feature rather than something you build, and the semantics are documented on <a href=\"https:\/\/www.smsgatewaycenter.com\/multi-channel-fallback\/\">the multi channel fallback page<\/a>. The commercial overview of the channel itself sits on <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-api\/\">the WhatsApp Business API product page<\/a>.<\/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\">The Production Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>apikey<\/code> header set on every call, <code>userid<\/code> sent alongside it, no password anywhere in a URL<\/li>\n\n\n\n<li>Two transports implemented, one per generation, sharing an auth decorator<\/li>\n\n\n\n<li><code>output=json<\/code> set once in the shared request builder<\/li>\n\n\n\n<li>Branching on the top level <code>status<\/code> string only, never on <code>statusCode<\/code> or <code>code<\/code><\/li>\n\n\n\n<li>Two stage parse in place: read <code>status<\/code>, return on failure, bind the payload only on success<\/li>\n\n\n\n<li>Long numeric protection applied at the transport layer before any parse<\/li>\n\n\n\n<li>All identifiers stored as <code>TEXT<\/code>, never <code>BIGINT<\/code><\/li>\n\n\n\n<li><code>mediaList<\/code> double decode implemented and named clearly<\/li>\n\n\n\n<li><code>download<\/code> flag set explicitly on every media GET<\/li>\n\n\n\n<li>Media upload scoped by <code>wabaNumber<\/code>, reads filtered client side by the row&#8217;s own <code>wabaNumber<\/code><\/li>\n\n\n\n<li>Template names stored lower case, taken from the read response not the submission<\/li>\n\n\n\n<li>Both <code>whatsAppStatus<\/code> and <code>systemStatus<\/code> persisted and both required before send<\/li>\n\n\n\n<li>Template identity modelled as name plus language, not name alone<\/li>\n\n\n\n<li>Template delete followed by a confirming re-read<\/li>\n\n\n\n<li><code>{{1}}<\/code> substitution routine separate from the other three channels and unit tested against the other delimiters<\/li>\n\n\n\n<li><code>buttons<\/code> payload built to Meta&#8217;s snake case field names<\/li>\n\n\n\n<li><code>footer<\/code> validated as variable free before submission<\/li>\n\n\n\n<li><code>header<\/code> and <code>footer<\/code> length capped at 60 characters client side<\/li>\n\n\n\n<li>Delivery report window expressed with full timestamps, not whole days<\/li>\n\n\n\n<li>Cursor paging loop driven by <code>data.cursors.next<\/code> fed back as <code>startCursor<\/code><\/li>\n\n\n\n<li>Loop terminated on <code>data.pages.current<\/code> reaching <code>data.pages.last<\/code><\/li>\n\n\n\n<li><code>submitTime<\/code> string and <code>deliveryTime<\/code> integer normalised to one type at the mapper<\/li>\n\n\n\n<li>Read counted from populated <code>readTime<\/code>, not from the <code>status<\/code> string<\/li>\n\n\n\n<li><code>billingModel<\/code> stored verbatim as text and used as the invoice join key<\/li>\n\n\n\n<li><code>globalErrorCode<\/code> stored as text, with no shared error map across channels<\/li>\n\n\n\n<li>Inbox poller running with <code>limit=200<\/code> and a high water mark on <code>incomingId<\/code><\/li>\n\n\n\n<li>Inbox <code>charges<\/code> summed into the same reconciliation as outbound cost<\/li>\n\n\n\n<li><code>agentName<\/code> value of <code>\"NA\"<\/code> normalised to null at the mapper<\/li>\n\n\n\n<li>Reply threading correlated on <code>mobileNo<\/code> plus a narrow time window<\/li>\n\n\n\n<li>Analytics delivery rate computed with Submitted as the denominator<\/li>\n\n\n\n<li>Analytics funnel charted as a funnel, with Read understood as a subset of Delivered<\/li>\n\n\n\n<li><code>amount<\/code> and <code>charges<\/code> both parsed through a decimal type, never a float<\/li>\n\n\n\n<li>Deserialiser configured to ignore unknown properties<\/li>\n\n\n\n<li>No feature built on a response field whose purpose you cannot state<\/li>\n\n\n\n<li>403 responses treated as configuration and never retried<\/li>\n\n\n\n<li>One end to end reconciliation performed by hand before go live<\/li>\n\n\n\n<li>Alerting on the ratio of report rows ingested to messages submitted, not on the delivered to failed ratio<\/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\">Some behaviours on this surface are not pinned down by anything you can read, and you will not find them out by staring at a schema. Each item below gives the choice that stays correct whichever way the behaviour 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. Reconcile on the composite, not on any single identifier.<\/strong> A send returns <code>messageId<\/code>. A report row carries <code>uuId<\/code> and <code>msgId<\/code>. An inbox row carries <code>waMsgId<\/code>. Rather than guessing which pair lines up, key your own outbound table on the tuple of recipient number, your own submit timestamp, and <code>wabaNumber<\/code>, and store every identifier you receive as additional columns. Match report rows on that tuple within a small window. The tuple is stable regardless of which identifier turns out to be canonical, and once you have confirmed the mapping empirically you can promote the winning column to the primary match with a one line change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Send one recipient per call when you need per message accounting.<\/strong> The send response returns a single <code>messageId<\/code> and a single <code>mobile<\/code>. Whether a comma separated list fans out into several billable rows or is recorded as one unit is not something you want to discover during a billing dispute. Loop in your own code, one call per recipient, and every message gets its own identifier, its own row and its own cost line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Confirm template state by reading, never by assuming a string.<\/strong> Both approval fields read <code>pending<\/code> in the documented sample, and the values that indicate a cleared state are best learned from your own account. Create one template, poll every thirty seconds, log the raw values as they change, and pin the constants you observe. One afternoon of watching gives you a correct constant for the life of the integration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Set <code>download<\/code> explicitly on every media read.<\/strong> One URL and one verb serve both the metadata read and the binary fetch, distinguished only by that flag. Passing it on every call means a change to whatever the default is cannot turn a JSON parse into a stream of image bytes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Treat template delete as asynchronous and confirm it.<\/strong> The response says accepted, not deleted. Issue the delete, wait, re-read the list filtered to that name and language, and only then mark it gone in your own store. The round trip costs one GET and removes the ambiguity entirely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Filter the media library client side by <code>wabaNumber<\/code>.<\/strong> Reads are account scoped while writes are WABA scoped. If you operate one WABA number today, nothing goes wrong. If you add a second, the library read starts returning both. Filtering on the row&#8217;s own <code>wabaNumber<\/code> from day one means adding the second number changes nothing in your code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Normalise every timestamp to UTC at the mapper, and record the offset you assumed.<\/strong> The report row mixes a quoted string timestamp with unquoted integer ones, and the timezone the platform uses when it expands a date range is not something you should infer. Convert everything to UTC epoch milliseconds at the point of ingestion, store the assumed offset as a column, and a later correction becomes an update statement rather than a migration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Bound your delivery report window by time, not by page count.<\/strong> Cursor paging tells you <code>current<\/code> and <code>last<\/code>, but a long lived poller that trusts the page count and nothing else will loop indefinitely if the window keeps growing while it reads. Fix the window to a closed interval that has already ended, page until current equals last, then move the window forward. The loop terminates by construction.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Treat <code>read<\/code> as a subset of <code>delivered<\/code> in every aggregate you build.<\/strong> The summary metrics do not sum to the submitted total, and the delivery rows publish <code>read<\/code> alongside <code>delivered<\/code>. Model your funnel so that read implies delivered, and your totals stay correct whether or not the platform later changes how it counts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Store <code>billingModel<\/code> and <code>globalErrorCode<\/code> as opaque text with no local enumeration.<\/strong> Both carry codes whose full value space you have not seen. An enum with a default branch of &#8220;unknown&#8221; loses the actual value. Text preserves it, and you can add the mapping later from data you already have rather than from data you threw away.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Poll the inbox on <code>incomingId<\/code> order rather than on timestamp.<\/strong> Rows come back newest first and <code>incomingId<\/code> appears to increment. A high water mark on the identifier is immune to clock skew and to two messages sharing a millisecond, both of which will eventually happen to a timestamp based cursor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Apply the long numeric rewrite at the transport, not at the call site.<\/strong> Any endpoint on this family could return an unquoted long value in a field that is quoted today. Doing the rewrite once, in the layer that turns a response body into a parsed object, means a future change on any endpoint cannot corrupt anything, and no future caller has to remember.<\/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>Which base path do I use for WhatsApp?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both. <code>https:\/\/unify.smsgateway.center\/WAApi\/<\/code> serves send, media, template and report. <code>https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/<\/code> serves inbox and analytics, with a legacy alias that drops the <code>v1<\/code> segment. Both take <code>output=json<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I authenticate?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Send your API key in the <code>apikey<\/code> request header and include <code>userid<\/code> as a parameter on every call. The header is accepted on every endpoint of this family. Never put a password in a query string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my client throw a JSON type error only in production?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because on error the payload key comes back as an empty array, including on the endpoints that return an object on success. Parse the envelope loosely, check the top level <code>status<\/code> string, return early on failure, and bind the typed payload only after that check passes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why do my delivery report identifiers not match anything?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>uuId<\/code> is an unquoted nineteen digit number and exceeds the safe integer range in JavaScript, so a plain <code>JSON.parse<\/code> rounds it. Rewrite the raw body to quote long numeric literals before parsing, or use a big integer aware reader, and store the result as text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I delete a WhatsApp template?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Send a DELETE to <code>WAApi\/template<\/code> with <code>wabaNumber<\/code>, <code>templateName<\/code> and <code>language<\/code>. The response reads accepted rather than deleted, so re-read the template list to confirm the row is gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my template have two status fields?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because it passes through two independent approvals. <code>whatsAppStatus<\/code> reflects the Meta side and <code>systemStatus<\/code> reflects the platform side. Require both before sending.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What placeholder syntax do WhatsApp templates use?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Numbered double curly braces: <code>{{1}}<\/code>, <code>{{2}}<\/code>, <code>{{3}}<\/code>. Provide a <code>bodySample<\/code> and a <code>headerSample<\/code> with realistic values, since the sample is what gets reviewed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I put a variable in the footer?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. The footer is fixed text of up to 60 characters. The header accepts one variable and the body accepts several.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is <code>mediaList<\/code> an array?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. It is a string containing a JSON array, so it needs a second parse. The rows inside it carry <code>mediaId<\/code> as a quoted string and <code>wabaNumber<\/code> as an unquoted number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I get delivery status pushed to my server?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the endpoints described here, delivery status and inbound messages are both retrieved by polling. The SMS side of the platform supports an outbound webhook for delivery receipts; on WhatsApp, run a poller against <code>WAApi\/report<\/code> with a closed time window and cursor paging.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does inbound WhatsApp traffic cost money?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inbox rows carry a <code>charges<\/code> field, so inbound is a billable line item. Sum it into the same reconciliation as your outbound cost rather than treating replies as free.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the widest date range I can query?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Analytics accepts up to 365 days in a single call, which is the widest range on the platform. The delivery report takes an explicit <code>fromDate<\/code> and <code>toDate<\/code> at full second granularity and pages with cursors rather than a range cap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Which denominator should I use for a delivery rate?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Submitted. The analytics summary returns it directly. Using delivered over delivered plus failed produces a ratio that freezes at a healthy value when reports stop arriving, which hides exactly the outage you need to detect.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I reuse my SMS integration code for WhatsApp?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Only the transport. The envelope, the identifier types, the delivery row field names, the placeholder syntax and the error code types all differ. Write a separate mapper per channel that produces one shared internal record, and keep the differences inside the mappers.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Ready to put this on the wire?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">Talk to the SMSGatewayCenter team<\/a> about enabling WhatsApp Business API access on your account, or <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">start with the developer documentation<\/a> and send your first template message today.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Recent Articles<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\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\n\n\n<li><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/campaign-splitting-one-send-several-transactions\/\">Campaign Splitting: What Happens When One Send Becomes Several Transactions<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>A field-level reference to the WhatsApp Business API on SMSGatewayCenter. Ten operations across two base paths, five distinct response envelopes, the media and template lifecycles, the delivery row compared against SMS, RCS and Telegram, and the parsing traps that silently corrupt identifiers.<\/p>\n","protected":false},"author":118,"featured_media":2967,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[1439,1991,2231,2250,697,632,2249,1981,2251,1960],"class_list":["post-2966","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-api-integration","tag-developer-guide","tag-messaging-api","tag-waapi","tag-whatsapp-api","tag-whatsapp-business-api","tag-whatsapp-delivery-report","tag-whatsapp-inbox","tag-whatsapp-media","tag-whatsapp-templates"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2966","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=2966"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2966\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2967"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2966"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2966"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2966"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}