{"id":3031,"date":"2026-09-21T13:09:16","date_gmt":"2026-09-21T07:39:16","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3031"},"modified":"2026-09-21T13:11:37","modified_gmt":"2026-09-21T07:41:37","slug":"reconciling-messaging-invoice-four-channels","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/","title":{"rendered":"Reconciling a Messaging Invoice Across Four Channels: Credits, Currency and the Rate Plan API"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Your wallet moves in credits, your delivery reports report currency, and no endpoint converts one into the other for you. Here is the complete cost contract for SMS, RCS, Telegram and WhatsApp, and a reconciliation job that balances to the rupee.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/reconciling-messaging-invoice-four-channels.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/reconciling-messaging-invoice-four-channels-1024x584.webp\" alt=\"Four columns of unequal stacked segments converging on a single ledger rail through a bridge shaped conversion motif, illustrating four messaging channels reporting cost in different units against one invoice.\" class=\"wp-image-3032\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/reconciling-messaging-invoice-four-channels-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/reconciling-messaging-invoice-four-channels-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/reconciling-messaging-invoice-four-channels-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/reconciling-messaging-invoice-four-channels.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Four channels, four cost representations, one invoice. The conversion between them is yours to build.<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Table of Contents<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#four-representations\">The Four Cost Representations at a Glance<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#credits-versus-currency\">Why the Wallet and the Report Disagree<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#rate-plan-api\">The Rate Plan API, the Only Bridge You Get<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#sms-cost\">SMS: the Line Total Is a Multiplication You Perform<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#rcs-cost\">RCS: Four Decimals, Two of Which Carry Information<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#telegram-cost\">Telegram: Two Decimals and No Published Rate Card<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#whatsapp-cost\">WhatsApp: Aggregate Outbound, Per Message Inbound<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#submission-billing\">Billing Fires at Submission, Not at Delivery<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#account-endpoints\">The Account Endpoints and What They Leave Out<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#envelope-differences\">Envelope and Type Differences Across the Billing Surfaces<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#outside-the-api\">The Invoice Lines That No Endpoint Returns<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#reconciliation-schema\">A Reconciliation Schema That Survives All Four Channels<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#reconciliation-algorithm\">The Reconciliation Algorithm, Step by Step<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#language-samples\">Four Language Samples, Four Different Traps<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#decimal-handling\">Decimal Handling and Rounding Strategy<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#ten-mistakes\">Ten Mistakes That Produce a Wrong Total<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#decision-matrix\">Which Source to Trust When Two Disagree<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#checklist\">Production Checklist<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/claude.ai\/cowork\/local_ec33ce4d-01f1-455c-adb9-43ad916a6d47#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\">Four channels report cost four different ways, and only three of them will cost a single message for you. SMS returns <code>amount<\/code> as an unquoted float that is the price of <strong>one part<\/strong>, alongside <code>cost<\/code> as an unquoted integer that is the <strong>part count<\/strong>, so the line total is a multiplication you perform yourself. RCS returns <code>amount<\/code> as a quoted string with four decimals, already whole. Telegram returns <code>charges<\/code> as a quoted string with two decimals, already whole. WhatsApp returns no cost field at all on its per-message delivery row; outbound WhatsApp cost arrives only as an aggregate <code>amount<\/code> from the analytics endpoint, grouped by <code>billingModel<\/code>. The one place WhatsApp does cost a single message is the inbox, where each inbound row carries <code>charges<\/code>. So on the only channel that bills you for receiving, receiving is the only direction you can price message by message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Underneath all of that sits a unit mismatch. Your wallet is denominated in <strong>credits<\/strong>, integers, visible through <code>SMSApi\/account\/readcredithistory<\/code>. Your delivery reports are denominated in <strong>currency<\/strong>. The only endpoint that converts between them is <code>SMSApi\/rateplan\/read<\/code>, it is read-only, and it publishes <strong>two<\/strong> rates for every route, <code>rate<\/code> and <code>dltRate<\/code>, so in India the conversion depends on whether the message carried a DLT template identifier. Build your reconciliation on the per-message rows, key every cost column to a decimal type parsed from the string form, treat submission rather than delivery as the billable event, and reconcile the credit ledger separately from the currency ledger rather than trying to make one explain the other.<\/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>Cost lives in four different shapes.<\/strong> SMS <code>amount<\/code> (unquoted float, per part) plus <code>cost<\/code> (unquoted integer, part count). RCS <code>amount<\/code> (quoted string, four decimals, whole message). Telegram <code>charges<\/code> (quoted string, two decimals, whole message). WhatsApp outbound: nothing per message, only an aggregate <code>amount<\/code> from analytics. WhatsApp inbound: <code>charges<\/code> per row.<\/li>\n\n\n\n<li><strong>SMS is the only channel where the row does not contain the line total.<\/strong> Multiply <code>amount<\/code> by <code>cost<\/code>. Every other channel hands you the finished figure.<\/li>\n\n\n\n<li><strong>WhatsApp is the only channel that bills inbound.<\/strong> A cost model built from outbound data alone under-counts WhatsApp and only WhatsApp. Sum the inbox <code>charges<\/code> for the same window and add it.<\/li>\n\n\n\n<li><strong><code>billingModel<\/code> is the invoice join key.<\/strong> It is the only field on the platform that tells you which commercial category a message was billed under, and it appears on the WhatsApp delivery row and as an analytics <code>groupBy<\/code> value.<\/li>\n\n\n\n<li><strong>Billing fires at submission.<\/strong> Credits are deducted when the message is accepted for the operator, not when it is delivered. A delivered-only sum under-counts every channel, and a failed message is still a billed message.<\/li>\n\n\n\n<li><strong>The wallet speaks credits, the reports speak currency.<\/strong> <code>account\/readcredithistory<\/code> returns integer credit movements as quoted strings. Nothing in the delivery reports is denominated in credits. <code>rateplan\/read<\/code> is the only published bridge.<\/li>\n\n\n\n<li><strong>The rate plan publishes two rates per route.<\/strong> <code>rate<\/code> and <code>dltRate<\/code>, both quoted strings with four decimals. In India a DLT-registered send is priced differently from a non-DLT send on the same route.<\/li>\n\n\n\n<li><strong><code>account\/readstatus<\/code> exposes exactly one balance, <code>smsBalance<\/code>.<\/strong> There is no WhatsApp, RCS or Telegram balance field anywhere in the developer API.<\/li>\n\n\n\n<li><strong><code>account\/readcredithistory<\/code> accepts no date range and no paging.<\/strong> Period-scoping the credit ledger is client-side work.<\/li>\n\n\n\n<li><strong>Rounding is not portable.<\/strong> Four decimals on RCS, two on Telegram and the WhatsApp inbox, and an unquoted float on SMS that must then be multiplied by an integer. Pick one internal precision and convert at the boundary.<\/li>\n\n\n\n<li><strong>Parse every cost as a decimal, never as a float.<\/strong> Two of these fields arrive as unquoted JSON numbers, so they are already binary floats by the time your deserialiser hands them over. Re-read them from the raw body if you need exactness.<\/li>\n\n\n\n<li><strong>Some invoice lines are not in the API at all.<\/strong> Platform subscription, agent seats, taxes and any one-time charges appear on the invoice and in no endpoint response. Reconcile the usage lines and account for the rest separately.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"four-representations\" class=\"wp-block-heading\">The Four Cost Representations at a Glance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before any code, the shape of the problem. This table is the whole article compressed, and it is the reason a single <code>parseCost()<\/code> helper cannot be shared across channels.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Channel<\/th><th>Where cost lives<\/th><th>Field<\/th><th>JSON type<\/th><th>Decimals in sample<\/th><th>Scope of the figure<\/th><\/tr><\/thead><tbody><tr><td>SMS<\/td><td><code>SMSApi\/reports\/status<\/code> row<\/td><td><code>amount<\/code><\/td><td>unquoted number<\/td><td><code>0.125<\/code><\/td><td>One part, not one message<\/td><\/tr><tr><td>SMS<\/td><td><code>SMSApi\/reports\/status<\/code> row<\/td><td><code>cost<\/code><\/td><td>unquoted number<\/td><td><code>1<\/code><\/td><td>Part count, not currency<\/td><\/tr><tr><td>RCS<\/td><td><code>rest\/rcs\/v1\/dlr<\/code> row<\/td><td><code>amount<\/code><\/td><td>quoted string<\/td><td><code>\"0.0000\"<\/code><\/td><td>Whole message<\/td><\/tr><tr><td>Telegram<\/td><td><code>rest\/tg\/v1\/dlr<\/code> row<\/td><td><code>charges<\/code><\/td><td>quoted string<\/td><td><code>\"0.50\"<\/code><\/td><td>Whole message<\/td><\/tr><tr><td>WhatsApp outbound<\/td><td><code>WAApi\/report<\/code> row<\/td><td>none<\/td><td>not present<\/td><td>not present<\/td><td>No per-message cost is returned<\/td><\/tr><tr><td>WhatsApp outbound<\/td><td><code>rest\/wa\/v1\/analytics<\/code> delivery row<\/td><td><code>amount<\/code><\/td><td>unquoted number<\/td><td><code>45.5<\/code><\/td><td>Aggregate over a group, not one message<\/td><\/tr><tr><td>WhatsApp inbound<\/td><td><code>rest\/wa\/v1\/inbox<\/code> row<\/td><td><code>charges<\/code><\/td><td>quoted string<\/td><td><code>\"0.00\"<\/code><\/td><td>One inbound message<\/td><\/tr><tr><td>Account ledger<\/td><td><code>SMSApi\/account\/readcredithistory<\/code> row<\/td><td><code>credits<\/code><\/td><td>quoted string<\/td><td><code>\"10\"<\/code><\/td><td>Credits, not currency<\/td><\/tr><tr><td>Rate card<\/td><td><code>SMSApi\/rateplan\/read<\/code> rate object<\/td><td><code>rate<\/code>, <code>dltRate<\/code><\/td><td>quoted strings<\/td><td><code>\"0.0050\"<\/code><\/td><td>Currency per message, per route<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Read down the JSON type column. Three quoted strings, three unquoted numbers, and one field that is not there. Read down the scope column and it gets worse: one figure is per part, one is a count rather than money, one is an aggregate, and the rest are per message. A helper that takes a row and returns a cost has to know which channel it is looking at before it can do anything at all, which is the argument for four small explicit extractors rather than one clever generic one.<\/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-four-cost-representations.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-four-cost-representations.svg\" alt=\"A four column comparison showing where cost lives on each messaging channel, the JSON type of each cost field, and whether the figure covers one part, one message or an aggregate group.\" class=\"wp-image-3034\"\/><\/a><figcaption class=\"wp-element-caption\">The same concept, cost, expressed in seven different ways across four channels. Only three of these rows will price a single outbound message.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"credits-versus-currency\" class=\"wp-block-heading\">Why the Wallet and the Report Disagree<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There are two ledgers on this platform and they are denominated in different units.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first is the <strong>credit ledger<\/strong>. <code>SMSApi\/account\/readcredithistory<\/code> returns movements against your wallet, and every one of them is an integer count of message credits:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"account\",\n    \"action\": \"readcredithistory\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 1,\n    \"historyList\": &#91;\n      {\n        \"history\": {\n          \"id\": \"307\",\n          \"creditsBefore\": \"0\",\n          \"credits\": \"10\",\n          \"creditsAfter\": \"10\",\n          \"product\": \"SMS\",\n          \"type\": \"CREDIT\",\n          \"transactionType\": \"1\",\n          \"addedTime\": \"1562969637101\",\n          \"creditComments\": \"test credits for testing\"\n        }\n      }\n    ]\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every scalar in that row is a quoted string, including the three credit counters and the epoch timestamp. The row itself is double wrapped: the useful object sits at <code>historyList[i].history<\/code>, not at <code>historyList[i]<\/code>. And <code>product<\/code> is <code>\"SMS\"<\/code>, which is the field that makes this ledger channel-aware. <code>type<\/code> carries <code>\"CREDIT\"<\/code> or <code>\"DEBIT\"<\/code> and tells you the direction of the movement, while <code>transactionType<\/code> carries <code>\"1\"<\/code> and distinguishes categories of movement such as a purchase against an adjustment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second is the <strong>currency ledger<\/strong>, which is what the delivery reports give you. Nothing in any delivery report is denominated in credits. <code>amount<\/code> and <code>charges<\/code> are money.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These two ledgers never meet in any response. No endpoint returns a row that carries both a credit movement and its currency equivalent. When finance asks why the wallet dropped by 4,182 credits in a month whose invoice reads a currency figure, the arithmetic that joins those two numbers is yours to write, and the rate card is the only published input to it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Practically, that means you maintain two reconciliations rather than one. The credit reconciliation asks whether the credits debited match the parts submitted, which is a pure integer comparison and should balance exactly. The currency reconciliation asks whether the money charged matches the parts submitted multiplied by the applicable rate, and it is the one that needs care about decimals. Keeping them separate is what lets you say which of the two is wrong when they disagree, instead of staring at a single blended number that is off by an amount you cannot attribute.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rate-plan-api\" class=\"wp-block-heading\">The Rate Plan API, the Only Bridge You Get<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/rateplan\/read<\/code> accepts POST or GET, takes nothing but your credentials and an optional <code>output<\/code>, and is explicitly read-only. It is the single published surface that states what a message costs before it is sent, and it is therefore the only thing that converts a credit count into a currency figure.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl --location 'https:\/\/unify.smsgateway.center\/SMSApi\/rateplan\/read' \\\n  --header 'apikey: YOUR_API_KEY' \\\n  --form 'userid=\"your_username\"' \\\n  --form 'output=\"json\"'\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Lead with the <code>apikey<\/code> header rather than putting a password in a form field or a query string, on this endpoint and on every other endpoint you call. The key is accepted as a request header throughout the platform, <code>userid<\/code> still travels alongside it because on this platform a token is an authorization and never an identity, and a rate card is exactly the sort of response you do not want sitting in a web server access log next to a plaintext password.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The response carries a <code>rates<\/code> array whose shape depends on one boolean:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"rateplan\",\n    \"action\": \"read\",\n    \"status\": \"success\",\n    \"msg\": \"Rate plan retrieved successfully\",\n    \"code\": 200,\n    \"count\": 1,\n    \"data\": {\n      \"mccMncRoutingEnabled\": false,\n      \"defaultCurrency\": \"USD\",\n      \"currencySymbol\": \"$\",\n      \"currencyPosition\": \"left\",\n      \"rates\": &#91;\n        {\n          \"mcc\": \"Default\",\n          \"mnc\": \"Default\",\n          \"country\": \"Default\",\n          \"countryCode\": \"Default\",\n          \"operator\": \"Default\",\n          \"rate\": \"0.0050\",\n          \"dltRate\": \"0.0010\",\n          \"sortName\": \"Default\"\n        }\n      ]\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three things in there change how you build.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>rate<\/code> and <code>dltRate<\/code> are two different prices for the same route.<\/strong> <code>rate<\/code> is the standard rate for an SMS on that route. <code>dltRate<\/code> applies to India DLT-registered templates. That means a reconciliation that multiplies every Indian part by a single figure is wrong for any account where the two values differ, and you cannot tell which rate applied by looking at the rate card alone. The discriminator is on the message row: a delivery report row that carries a populated <code>dltTemplateId<\/code> was a DLT-registered send. Join on that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The route key is MCC plus MNC, not the country code.<\/strong> When <code>mccMncRoutingEnabled<\/code> is <code>true<\/code> the array returns one object per country and operator pair, and the default entry is always the first element. When it is <code>false<\/code> you get the default entry and nothing else. The delivery report row gives you <code>country<\/code> and <code>network<\/code>, so your join is from <code>network<\/code> to <code>operator<\/code> within <code>country<\/code>, which is a string match and deserves a normalisation table rather than a direct comparison.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Both rates are quoted strings with four decimal places.<\/strong> They are strings for a reason, and the correct thing to do is keep them as strings until they enter a decimal type. Multiplying the string by an integer in a language with implicit coercion will silently produce a binary float and you will spend an afternoon explaining a rounding difference of a few paise.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two boundary conditions are worth handling explicitly before they surprise you in production. Sub-user accounts cannot read rate plans at all and receive error code <code>486<\/code>, because pricing lives on the parent account. And an administrator can hide pricing from an account entirely, in which case the call returns error code <code>489<\/code> with <code>status<\/code> set to <code>error<\/code>. Both are configuration states rather than transient faults, so detect them once at startup, surface them to an operator, and fall back to a locally configured rate table rather than retrying on a schedule forever.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is one structural detail in the error responses that matters more than it looks. On success the payload sits under <code>data<\/code>. On error, <code>data<\/code> is <strong>absent entirely<\/strong>, not present and empty:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"rateplan\",\n    \"action\": \"read\",\n    \"status\": \"error\",\n    \"msg\": \"Rate plan is not active for your account. Please contact administrator for pricing information\",\n    \"code\": 489\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you have already built against the Telegram and WhatsApp families you will have written defences against a payload key that flips from an object to an empty array on error. This endpoint does a third thing: it omits the key. A deserialiser that requires <code>data<\/code> to be present will throw here, and it will throw on the one response that actually explains what went wrong. The pattern that survives all three behaviours is the same one that works everywhere else on this platform: parse loosely, read only the top-level <code>status<\/code> string, return early on failure, and bind your typed model only after <code>status<\/code> says <code>success<\/code>.<\/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-credits-currency-bridge.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-credits-currency-bridge.svg\" alt=\"A flow showing the credit wallet denominated in integer credits on the left, the delivery reports denominated in currency on the right, and the rate plan endpoint as the only bridge between them, splitting into a standard rate path and a DLT rate path.\" class=\"wp-image-3033\"\/><\/a><figcaption class=\"wp-element-caption\">Two ledgers, two units, one read-only bridge. The bridge forks on whether the message carried a DLT template identifier.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"sms-cost\" class=\"wp-block-heading\">SMS: the Line Total Is a Multiplication You Perform<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The SMS delivery report row from <code>SMSApi\/reports\/status<\/code> is the only per-message cost surface on the platform that does not hand you the line total.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"country\": \"IN\",\n  \"amount\": 0.125,\n  \"msgType\": \"text\",\n  \"cost\": 1,\n  \"deliveryTime\": 1783887217000,\n  \"length\": 13,\n  \"channel\": \"API\",\n  \"msgId\": \"orderCreate\",\n  \"cause\": \"Template Mismatch\",\n  \"mobileNo\": 9177709xxxxx,\n  \"uuId\": \"6358884679059916409\",\n  \"dltTemplateId\": \"12011591428xxxxxxx\",\n  \"globalErrorCode\": 17,\n  \"cursorId\": 162438749,\n  \"network\": \"Idea\",\n  \"senderName\": \"SMSGAT\",\n  \"flashMsg\": false,\n  \"submitTime\": 1783887217389,\n  \"text\": \"test template\",\n  \"status\": \"FAILED\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>amount<\/code> is <code>0.125<\/code> and <code>cost<\/code> is <code>1<\/code>. The field named <code>cost<\/code> is not money. It is the <strong>number of parts<\/strong> the message was split into, and <code>amount<\/code> is the price of one of those parts. The line total for this row is <code>amount * cost<\/code>. For a single-part message the multiplication is invisible, which is exactly why this defect survives into production: everything balances in testing, where messages are short, and drifts in production, where a Unicode message at 71 characters silently becomes two parts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three further things on this row earn their place in a reconciliation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>dltTemplateId<\/code> is populated, which tells you this send was DLT-registered and therefore priced at <code>dltRate<\/code> rather than <code>rate<\/code>. That is your rate selector, and it is per row rather than per account.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>network<\/code> carries <code>\"Idea\"<\/code> and <code>country<\/code> carries <code>\"IN\"<\/code>, which together give you the route for the rate card join. Operator names drift and merge over time, so normalise both sides through a lookup table you control rather than comparing the strings directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>status<\/code> is <code>\"FAILED\"<\/code>, and this row still costs money. Which brings us to the section on when billing fires.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The types on this row are a trap in themselves. <code>amount<\/code> is an unquoted JSON number, so by the time a standard deserialiser hands it to you it is already an IEEE 754 double and <code>0.125<\/code> happens to be exactly representable while most rates are not. <code>cost<\/code> is an unquoted integer. <code>mobileNo<\/code> is unquoted while <code>uuId<\/code> is quoted, which is the opposite of the WhatsApp report and a reason to never share a row model between the two. <code>flashMsg<\/code> is a genuine boolean, one of very few on the platform.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the safe row key and the broader ingestion design, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report ingestion article<\/a> covers the identity problem in full. From here on, assume those rows are already landing in your database, and focus on the money columns.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rcs-cost\" class=\"wp-block-heading\">RCS: Four Decimals, Two of Which Carry Information<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The RCS delivery row from <code>rest\/rcs\/v1\/dlr<\/code> is simpler in one respect and stranger in another.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"uuId\": \"6226901107781419438\",\n  \"botName\": \"YourBot\",\n  \"mobileNo\": \"919876543210\",\n  \"msgId\": \"Zr6dILs7iZt1pvO\",\n  \"amount\": \"0.0000\",\n  \"msgTypeLabel\": \"RICH\",\n  \"directionLabel\": \"A2P\",\n  \"globalErrorCode\": 5007,\n  \"deliveryStatus\": \"FAILED\",\n  \"cause\": \"RCS Not Enabled\",\n  \"channelLabel\": \"API\",\n  \"submitTime\": 1788421063350,\n  \"deliveryTime\": 1788421066059,\n  \"readTime\": 0,\n  \"clickedTime\": 0\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>amount<\/code> is a <strong>quoted string with four decimal places<\/strong>, and it covers the whole message. RCS has no part concept, so there is no multiplication to perform. One row, one figure, done.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The four decimals deserve a moment. Published RCS rates in India are quoted in paise, which is two decimal places of real precision. A four decimal field carrying a two decimal value is not a problem, but it does mean your parser must not assume a fixed scale, and it means a naive string comparison between a rate card figure and a report figure will fail on trailing zeros. Compare decimals, never strings.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is also a <code>directionLabel<\/code> on this row carrying <code>\"A2P\"<\/code>. RCS supports inbound, through <code>rest\/rcs\/v1\/inbox<\/code>, and the inbox row carries no cost field at all. So for RCS the direction label on the outbound report is informational rather than a reconciliation dimension: every costed RCS row is outbound, because the inbound rows are not costed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One reconciliation-specific point about <code>globalErrorCode<\/code> 5007, <code>\"RCS Not Enabled\"<\/code>. That is a permanent condition for that handset, and in a fallback configuration it is the canonical trigger for re-sending over SMS. If your account runs multi-channel fallback then a single logical notification can produce an RCS row and an SMS row for the same recipient at nearly the same moment, and both may be billable. A reconciliation that counts logical notifications rather than channel rows will under-count in exactly the accounts that are hardest to explain. Count rows per channel, and if you need a business-level figure, aggregate upward from the rows rather than downward from your own send intent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full RCS wire contract, including the two API generations and the format parameter split, is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-messaging-api-reference-migration-from-sms\/\">RCS messaging API reference<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"telegram-cost\" class=\"wp-block-heading\">Telegram: Two Decimals and No Published Rate Card<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Telegram delivery row from <code>rest\/tg\/v1\/dlr<\/code> names its cost field differently again.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"logId\": \"501\",\n  \"uniqueId\": \"90001\",\n  \"uuId\": \"1234567890123456789\",\n  \"chatId\": \"123456789\",\n  \"msgId\": \"42\",\n  \"msgType\": \"text\",\n  \"charges\": \"0.50\",\n  \"sendMethod\": 1,\n  \"globalErrorCode\": 0,\n  \"deliveryStatus\": \"delivered\",\n  \"isFinal\": 1,\n  \"submitTime\": 1712345678901,\n  \"deliveryTime\": 1712345681000\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>charges<\/code>, not <code>amount<\/code>. A <strong>quoted string with two decimal places<\/strong>, covering the whole message. No part count, no multiplication.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two Telegram-specific properties change how the rows are gathered rather than how they are priced.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>sendMethod<\/code> on the summary endpoint uses <code>1<\/code> for outgoing and <code>2<\/code> for incoming, and the Telegram summary is the only summary on the platform that includes inbound traffic. That makes it tempting to reconcile Telegram from the summary rather than the rows. Resist it for the money columns: the summary gives counts, not charges, so the rows remain the only per-message cost source.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>isFinal<\/code> is unique to this channel and it is genuinely useful here. A row with <code>isFinal<\/code> set to <code>0<\/code> has not reached a terminal delivery state, which means its <code>charges<\/code> value may not be the last word. Reconcile on rows whose <code>isFinal<\/code> is <code>1<\/code>, and hold the rest for the next run rather than including a figure that may change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>uuId<\/code> on this row is a nineteen digit value, and it arrives quoted, which is the safe form. Note that the same is not true everywhere, and the WhatsApp report emits its nineteen digit <code>uuId<\/code> unquoted. Any JavaScript reconciliation job that consumes both must protect against the unquoted case, which is covered in the WhatsApp section below.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full Telegram contract, including the chat identifier model and the empty-array error behaviour, is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/telegram-messaging-api-chat-id-model\/\">Telegram messaging API reference<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"whatsapp-cost\" class=\"wp-block-heading\">WhatsApp: Aggregate Outbound, Per Message Inbound<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WhatsApp is the channel that breaks the pattern, and it breaks it in both directions at once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Outbound.<\/strong> The <code>WAApi\/report<\/code> row returns no cost field. Not a zero, not an empty string; the concept is simply not on the row:<\/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}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What the row does carry is <code>billingModel<\/code>, and that field is the most valuable thing in this entire article. It names the commercial category the message was billed under, and it is the only field on the platform that does so. No SMS, RCS or Telegram row carries an equivalent. Because an invoice is organised by commercial category rather than by message, <code>billingModel<\/code> is the join key between your row data and the invoice line items, and it is the reason the WhatsApp reconciliation is built around grouping rather than around summing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To get money out of WhatsApp outbound you go to <code>rest\/wa\/v1\/analytics<\/code> with <code>action=delivery<\/code>, which returns aggregate rows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"label\": \"MARKETING\",\n  \"billingModel\": \"MARKETING\",\n  \"country\": \"India\",\n  \"countryId\": 91,\n  \"waNumber\": \"9170396xxxxx\",\n  \"date\": \"2026-06-01\",\n  \"requested\": 500,\n  \"delivered\": 420,\n  \"read\": 280,\n  \"failed\": 12,\n  \"notSent\": 3,\n  \"amount\": 45.5\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>amount<\/code> here is an <strong>unquoted float<\/strong> and it is an <strong>aggregate over the group<\/strong>, not the price of one message. The grouping is controlled by <code>groupBy<\/code>, which accepts <code>billable<\/code>, <code>date<\/code>, <code>waNumber<\/code> and <code>country<\/code>. For invoice reconciliation, <code>groupBy=billable<\/code> is the one you want, because it produces exactly the categories the invoice is organised by. The maximum range is 365 days, the widest on the platform, so a full year can come back in a single call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two cautions on that endpoint. It is GET only and returns 405 for anything else. And its <code>analyticsList<\/code> payload key is an object on success and an empty array on error, which is the type flip the platform exhibits in several families, so bind it only after checking <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Inbound.<\/strong> The <code>rest\/wa\/v1\/inbox<\/code> row does carry a per-message cost:<\/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  \"profileName\": \"Rahul\",\n  \"message\": \"Hi, I need help with my order\",\n  \"isReplied\": 0,\n  \"timestamp\": 1752480000000,\n  \"charges\": \"0.00\",\n  \"agentName\": \"NA\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>charges<\/code>, a quoted string with two decimals. WhatsApp is the only one of the four channels where receiving a message is a billable event, and this is the only inbound row on the platform that carries a cost field. Any reconciliation that sums outbound only will under-count WhatsApp, and only WhatsApp, by whatever your inbound volume happens to be. Sum the inbox for the same window and add it as a separate line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That leaves WhatsApp with an odd symmetry: the direction you cannot price per message is the one you initiated, and the direction you can price per message is the one you did not. It is worth writing the comment in your code, because the next person to read it will assume you got it backwards.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One correctness hazard specific to this channel.<\/strong> <code>uuId<\/code>, <code>mobileNo<\/code> and <code>wabaNumber<\/code> on the <code>WAApi\/report<\/code> row are <strong>unquoted JSON numbers of up to nineteen digits<\/strong>. <code>3917313917152021246<\/code> exceeds <code>Number.MAX_SAFE_INTEGER<\/code>, so <code>JSON.parse<\/code> in any JavaScript runtime silently rounds it and you get a different identifier back than the one that was sent. Silently is the operative word; nothing throws. If your reconciliation runs on Node, rewrite the raw body before parsing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const raw = await response.text();\nconst safe = raw.replace(\/:\\s*(\\d{16,})(?=\\s*&#91;,}])\/g, ': \"$1\"');\nconst payload = JSON.parse(safe);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That turns every long integer into a string before the parser can damage it. Run it on the WhatsApp report body specifically; the SMS report already quotes its <code>uuId<\/code>, so the same treatment there is harmless but unnecessary. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js integration tutorial<\/a> covers the broader class of long-numeric hazards on this platform.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The complete WhatsApp contract, including all five response envelopes, is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp Business API wire contract<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"submission-billing\" class=\"wp-block-heading\">Billing Fires at Submission, Not at Delivery<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This single fact invalidates more reconciliation code than any type mismatch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The published billing terms are unambiguous. The applicable per-message rate is deducted from your wallet while sending, and credits are non-refundable once the message is successfully submitted to the operator. The billable event is <strong>submission<\/strong>, meaning the moment the platform accepts your message and hands it onward. It is not delivery, and it is not a successful delivery receipt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three consequences follow, and all three are counter-intuitive enough that somebody on your team will argue with them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A failed message is a billed message.<\/strong> The SMS row shown above has <code>status<\/code> set to <code>\"FAILED\"<\/code> with <code>cause<\/code> <code>\"Template Mismatch\"<\/code>, and it carries a real <code>amount<\/code>. Filtering your cost query to delivered rows will produce a total lower than your invoice every single month, by an amount that varies with your failure rate, which is exactly the kind of drift that looks like a rounding bug and is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your sum must be over submitted rows.<\/strong> Use <code>submitTime<\/code> as the period boundary, not <code>deliveryTime<\/code>. A message submitted at 23:58 on the last day of the month and delivered at 00:04 on the first day of the next belongs to the earlier invoice. Slicing on delivery time moves that row across the period boundary and puts both months out by the same figure in opposite directions, which is the hardest kind of discrepancy to spot because the annual total still balances.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The denominator for any cost-per-message metric is submitted, not delivered.<\/strong> This is the same denominator trap that breaks delivery-rate dashboards, described in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability article<\/a>. For cost it bites harder, because a cost-per-delivered-message figure rises when delivery degrades even though your spend has not changed, which sends people looking for a pricing problem when they have a routing problem.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a useful corollary for anyone building a pre-send budget check. Because billing fires at submission, a test suite that really sends really bills, and a retry storm costs real money at the moment of retry rather than at the moment of eventual success. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/\">testing article<\/a> covers how to build a suite that does not quietly spend your balance, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">retry strategy article<\/a> covers which failures are worth paying to retry.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"account-endpoints\" class=\"wp-block-heading\">The Account Endpoints and What They Leave Out<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three endpoints under <code>SMSApi\/account\/<\/code> and <code>SMSApi\/rateplan\/<\/code> make up the billing surface. Their gaps shape the reconciliation as much as their contents do.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>SMSApi\/account\/readstatus<\/code><\/strong> returns the current account state:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"account\",\n    \"action\": \"readstatus\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 4,\n    \"account\": {\n      \"expDate\": \"1620153000000\",\n      \"endHour\": \"-1\",\n      \"startHour\": \"-1\",\n      \"smsBalance\": \"1\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>smsBalance<\/code> is the only balance field in the developer API, it is a quoted string, and its name is accurate: it is the SMS balance. There is no <code>whatsappBalance<\/code>, no <code>rcsBalance<\/code> and no <code>telegramBalance<\/code> on this or any other endpoint. For a multi-channel account, this endpoint answers one quarter of the question of how much credit you have left, and the portal is where the rest lives.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>count<\/code> here is <code>4<\/code>, and there are four keys in the <code>account<\/code> object. On <code>readcredithistory<\/code>, <code>count<\/code> is the number of rows. On <code>rateplan\/read<\/code> it is the number of rate entries. Do not write a shared helper that reads <code>count<\/code> and assumes it means rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>expDate<\/code> is worth wiring into an alert. It is the account expiry as an epoch in milliseconds, quoted, and credit validity is tied to it. Published terms state that units carry forward if the account is renewed within the due date, which makes the date a real financial deadline rather than an administrative one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>SMSApi\/account\/readcredithistory<\/code><\/strong> returns the credit ledger, and its parameter list is the finding. It documents only credentials and <code>output<\/code>. There is no <code>fromDate<\/code>, no <code>toDate<\/code>, no <code>page<\/code> and no <code>limit<\/code>. Whatever the endpoint returns, it returns in one response, and period-scoping is something you do after the fact using the quoted <code>addedTime<\/code> epoch on each row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Design around that rather than against it. Pull the ledger on a schedule, insert rows into your own table keyed on the <code>id<\/code> field so repeated pulls are idempotent, and run your period queries against your own copy. That gives you the date filtering the endpoint does not offer, it gives you history that survives whatever retention the platform applies, and it means a finance question about March does not require a call that returns everything since account opening.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>SMSApi\/rateplan\/read<\/code><\/strong> is covered in full above. Its relevant property here is that it returns the rate card as it is <strong>right now<\/strong>. It carries no effective date, no version and no history. If your rates are renegotiated mid-period, the card you read after the change will not price the messages you sent before it. Snapshot the card daily into your own table with the date you read it, and price each message against the snapshot that was current on its <code>submitTime<\/code>. That one habit turns an unanswerable question into a join.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"envelope-differences\" class=\"wp-block-heading\">Envelope and Type Differences Across the Billing Surfaces<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The billing endpoints are a small family and they still disagree with each other. This table is worth keeping next to your deserialisers.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Surface<\/th><th>Status code field<\/th><th>Its JSON type<\/th><th><code>count<\/code> semantics<\/th><th>Payload key<\/th><th>Payload on error<\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/rateplan\/read<\/code><\/td><td><code>code<\/code><\/td><td>unquoted number<\/td><td>number of rate entries<\/td><td><code>data<\/code> (object)<\/td><td>key absent entirely<\/td><\/tr><tr><td><code>SMSApi\/account\/readstatus<\/code><\/td><td><code>code<\/code><\/td><td>quoted string<\/td><td>number of keys in <code>account<\/code><\/td><td><code>account<\/code> (object)<\/td><td>no error sample published<\/td><\/tr><tr><td><code>SMSApi\/account\/readcredithistory<\/code><\/td><td><code>code<\/code><\/td><td>quoted string<\/td><td>number of rows<\/td><td><code>historyList<\/code> (array)<\/td><td>no error sample published<\/td><\/tr><tr><td><code>SMSApi\/reports\/status<\/code><\/td><td><code>code<\/code><\/td><td>quoted string<\/td><td>object with <code>total<\/code> and <code>current<\/code><\/td><td><code>reports_dlrList<\/code> (array)<\/td><td>no error sample published<\/td><\/tr><tr><td><code>rest\/rcs\/v1\/dlr<\/code><\/td><td><code>statusCode<\/code><\/td><td>quoted string<\/td><td><code>totalRecords<\/code> at envelope level<\/td><td><code>dlrList<\/code> (array)<\/td><td>array<\/td><\/tr><tr><td><code>rest\/tg\/v1\/dlr<\/code><\/td><td><code>statusCode<\/code><\/td><td>quoted string<\/td><td><code>totalRecords<\/code> at envelope level<\/td><td><code>dlrList<\/code> (array)<\/td><td>empty array<\/td><\/tr><tr><td><code>WAApi\/report<\/code><\/td><td><code>code<\/code><\/td><td>unquoted number<\/td><td><code>counts<\/code> object, nested, plural<\/td><td><code>data.records<\/code> (array)<\/td><td>no error sample published<\/td><\/tr><tr><td><code>rest\/wa\/v1\/analytics<\/code><\/td><td><code>statusCode<\/code><\/td><td>quoted string<\/td><td>not applicable<\/td><td><code>analyticsList<\/code> (object)<\/td><td>empty array<\/td><\/tr><tr><td><code>rest\/wa\/v1\/inbox<\/code><\/td><td><code>statusCode<\/code><\/td><td>quoted string<\/td><td><code>totalRecords<\/code> at envelope level<\/td><td><code>inboxList<\/code> (array)<\/td><td>array<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two names for the status code, two JSON types for each of them, five different meanings for a field called <code>count<\/code>, and three distinct behaviours for the payload key on error. The rule that survives all of it is the one that holds everywhere on this platform: <strong>branch on the top-level <code>status<\/code> string and nothing else.<\/strong> Never on <code>code<\/code>, never on <code>statusCode<\/code>, never by parsing <code>msg<\/code> or <code>reason<\/code>. Then bind your typed model only after <code>status<\/code> reads <code>success<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In a statically typed language that means a two-stage parse. Deserialise into a loose envelope that has a <code>status<\/code> string and nothing else required, check it, and only then deserialise the payload into the strict model. Jackson, <code>encoding\/json<\/code> with a <code>json.RawMessage<\/code>, <code>serde_json::Value<\/code>, and <code>System.Text.Json<\/code> with a <code>JsonElement<\/code> all support this directly, and without it a reconciliation job will throw on the error responses and report a parse failure rather than the reason the platform actually gave. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-java-spring-boot-integration\/\">Java and Spring Boot article<\/a> works the pattern through in full.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"outside-the-api\" class=\"wp-block-heading\">The Invoice Lines That No Endpoint Returns<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A reconciliation is honest about its own scope. Several things that appear on a messaging invoice are not exposed by any endpoint in the developer API, and a job that tries to explain the invoice total from API data alone will always come up short.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Platform subscription.<\/strong> WhatsApp Business API on this platform is sold as a monthly or yearly plan with a set number of agent seats and an allowance of free conversations, and the plan fee is separate from per-message charges. That subscription line appears on the invoice and in no API response. Neither does the per-seat charge for agents beyond the plan allowance.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Conversation charges.<\/strong> The WhatsApp commercial model distinguishes user-initiated and business-initiated conversations, and those are billed separately from the plan. The per-message rows carry <code>billingModel<\/code>, which tells you the category, but the category-to-price mapping is not published through any endpoint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Taxes.<\/strong> Published rates are quoted exclusive of tax, and the applicable rate is applied on the invoice. No endpoint returns a tax component.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One-time and optional charges.<\/strong> Setup fees, verification services and similar items are invoice lines with no API representation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical consequence is that your reconciliation should produce a <strong>usage subtotal<\/strong>, not an invoice total, and it should reconcile that subtotal against the corresponding section of the invoice rather than against the bottom line. Model the non-usage lines as a small set of explicitly configured constants in your own system, reviewed when a contract changes, and present them as a separate block. A report that says &#8220;usage matches to within two paise, and the following four lines are configured rather than measured&#8221; is a report that finance can act on. A report that says &#8220;we are 1,247 rupees out&#8221; is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the commercial context of what drives the per-message figure in the first place, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">bulk SMS pricing article<\/a> covers the inputs, and the pricing pages for each channel carry the current published slabs.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reconciliation-schema\" class=\"wp-block-heading\">A Reconciliation Schema That Survives All Four Channels<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One table, four channels, and every decision below defended.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE message_cost (\n    id                BIGSERIAL PRIMARY KEY,\n    channel           TEXT        NOT NULL,\n    provider_uuid     TEXT        NOT NULL,\n    direction         TEXT        NOT NULL,\n    submit_time       TIMESTAMPTZ NOT NULL,\n    delivery_time     TIMESTAMPTZ,\n    is_final          BOOLEAN     NOT NULL DEFAULT FALSE,\n    delivery_status   TEXT,\n    unit_amount       NUMERIC(12,6),\n    unit_count        INTEGER     NOT NULL DEFAULT 1,\n    line_total        NUMERIC(12,6) GENERATED ALWAYS AS\n                          (COALESCE(unit_amount, 0) * unit_count) STORED,\n    currency          TEXT        NOT NULL,\n    billing_model     TEXT,\n    route_country     TEXT,\n    route_operator    TEXT,\n    dlt_template_id   TEXT,\n    rate_card_date    DATE,\n    raw_cost_field    TEXT,\n    ingested_at       TIMESTAMPTZ NOT NULL DEFAULT now(),\n    UNIQUE (channel, provider_uuid, direction)\n);\n\nCREATE INDEX message_cost_period\n    ON message_cost (submit_time, channel)\n    WHERE is_final;\n\nCREATE INDEX message_cost_billing_model\n    ON message_cost (billing_model, submit_time)\n    WHERE billing_model IS NOT NULL;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>unit_amount<\/code> and <code>unit_count<\/code> are separate columns, and <code>line_total<\/code> is generated.<\/strong> This is what makes one table work for all four channels. On SMS you write <code>amount<\/code> into <code>unit_amount<\/code> and <code>cost<\/code> into <code>unit_count<\/code>. On RCS, Telegram and the WhatsApp inbox you write the cost field into <code>unit_amount<\/code> and leave <code>unit_count<\/code> at its default of one. The generated column then holds the correct line total everywhere, and nobody downstream has to remember which channel needs a multiplication.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>NUMERIC(12,6)<\/code>, never a floating point type.<\/strong> Six decimal places comfortably holds the four that RCS and the rate card publish, with room for an intermediate calculation. A <code>DOUBLE PRECISION<\/code> column will reproduce the exact binary representation error you are trying to eliminate, and it will do so on the one report somebody prints for an auditor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>provider_uuid<\/code> is TEXT and the unique key includes <code>direction<\/code>.<\/strong> These identifiers run to nineteen digits, which overflows a 64-bit signed integer in some of their forms and overflows JavaScript&#8217;s safe range in all of them, so they are strings in the database as well as in transit. <code>direction<\/code> is in the key because WhatsApp produces both inbound and outbound rows and their identifier namespaces are unrelated.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>billing_model<\/code> is nullable and indexed.<\/strong> Only WhatsApp populates it. It is indexed because the invoice is organised by that dimension, so the grouping query that produces your reconciliation report is the single most frequent query against this table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>rate_card_date<\/code> records which snapshot priced this row.<\/strong> Combined with a small <code>rate_card_snapshot<\/code> table keyed on the date you read the card, this turns &#8220;which rate applied in March&#8221; from an argument into a join. Without it, a rate change mid-period is unreconstructable after the fact.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>raw_cost_field<\/code> keeps the string exactly as it arrived.<\/strong> When somebody disputes a figure, the ability to show the literal bytes the platform sent, before any parsing, ends the conversation in a minute rather than an afternoon. It costs a few bytes per row and it has never once been regretted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>is_final<\/code> gates the period index.<\/strong> Telegram publishes this directly. For the other three channels, derive it: a row is final when its <code>delivery_status<\/code> is in your configured terminal set. The partial index means your monthly aggregate never has to consider rows that are still moving.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This table sits alongside the outbound message table rather than replacing it; the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table article<\/a> covers the sibling schema and the three-level identity model that links them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reconciliation-algorithm\" class=\"wp-block-heading\">The Reconciliation Algorithm, Step by Step<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Six steps, run once per billing period, idempotent at every stage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step one. Snapshot the rate card before you need it.<\/strong> Call <code>rateplan\/read<\/code> daily on a schedule and insert the result into a snapshot table keyed on the read date. Do this from the day you start, not from the day finance first asks a question, because the endpoint has no history and yesterday&#8217;s card is unrecoverable once it changes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step two. Pull the per-message rows for the period, sliced on submit time.<\/strong> For each channel, query the delivery report with a closed date window whose boundaries are <code>submitTime<\/code> values, and page to exhaustion using that channel&#8217;s paging mechanism. Overlap the window by a few minutes at each end and rely on the unique key to absorb the duplicates, which is cheaper than reasoning about boundary rows. Write every row into <code>message_cost<\/code> with an upsert.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step three. Add the WhatsApp inbox for the same window.<\/strong> Query <code>rest\/wa\/v1\/inbox<\/code> over the identical date range and write each row with <code>direction<\/code> set to inbound and its <code>charges<\/code> value into <code>unit_amount<\/code>. This is the step that is easiest to forget and the only one whose omission produces a shortfall on exactly one channel.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step four. Price the rows that need pricing, and check the ones that do not.<\/strong> Every row already carries its own cost figure, so pricing is a verification rather than a calculation: join each row to the rate card snapshot current at its <code>submit_time<\/code>, select <code>dltRate<\/code> when <code>dlt_template_id<\/code> is populated and <code>rate<\/code> otherwise, match the route on normalised country and operator, and compare the expected figure to <code>unit_amount<\/code>. Rows that disagree are the interesting output of the whole job. Record the variance rather than overwriting the reported figure; the platform&#8217;s number is the one on the invoice, and yours is the one that asks a question.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step five. Group by the dimension the invoice uses.<\/strong> For WhatsApp that is <code>billing_model<\/code>, which maps onto the invoice categories directly and can be cross-checked against <code>rest\/wa\/v1\/analytics<\/code> with <code>groupBy=billable<\/code> over the same window. For the other three channels, group by channel and by route. Produce a subtotal per group, computed on <code>line_total<\/code>, in decimal arithmetic throughout.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step six. Reconcile the credit ledger separately.<\/strong> Pull <code>account\/readcredithistory<\/code>, filter to the period on <code>addedTime<\/code>, sum the <code>DEBIT<\/code> rows per <code>product<\/code>, and compare against the sum of <code>unit_count<\/code> over the same period for the corresponding channel. This is integer arithmetic and it should balance exactly. When it does not, the discrepancy is in row capture rather than in pricing, which tells you immediately which of the two ledgers to go and investigate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The output of a run is three numbers per group: the platform&#8217;s reported spend, your expected spend, and the variance. A run that reports zero variance across every group has genuinely reconciled. A run that reports a consistent small variance on one route has found a rate card mismatch. A run that reports a variance proportional to volume has usually found a missing multiplication on SMS.<\/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 is short and each demonstrates a hazard specific to that runtime.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">PHP: the part count multiplication<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The documented cost calculation on the rate plan page multiplies a rate by a message count. That is correct only when every message is a single part. This version multiplies by parts.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n$ch = curl_init('https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status');\ncurl_setopt_array($ch, &#91;\n    CURLOPT_RETURNTRANSFER =&gt; true,\n    CURLOPT_POST           =&gt; true,\n    CURLOPT_HTTPHEADER     =&gt; &#91;'apikey: ' . getenv('SGC_API_KEY')],\n    CURLOPT_POSTFIELDS     =&gt; http_build_query(&#91;\n        'userid'    =&gt; getenv('SGC_USERID'),\n        'method'    =&gt; 'getDlr',\n        'fromdate'  =&gt; '2026-09-01',\n        'todate'    =&gt; '2026-09-30',\n        'pageLimit' =&gt; '500',\n        'output'    =&gt; 'json',\n    ]),\n]);\n$body = curl_exec($ch);\ncurl_close($ch);\n\n$payload = json_decode($body, true);\nif (($payload&#91;'response']&#91;'status'] ?? '') !== 'success') {\n    throw new RuntimeException($payload&#91;'response']&#91;'msg'] ?? 'unknown error');\n}\n\n$total = '0';\nforeach ($payload&#91;'response']&#91;'reports_dlrList'] as $row) {\n    $unit  = (string) ($row&#91;'amount'] ?? '0');\n    $parts = (string) ($row&#91;'cost'] ?? '1');\n    $total = bcadd($total, bcmul($unit, $parts, 6), 6);\n}\n\necho 'Usage subtotal: ' . $total . PHP_EOL;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>bcmul<\/code> and <code>bcadd<\/code> keep the arithmetic in decimal string form from start to finish. PHP&#8217;s native float would introduce the error at the first addition and compound it across every row.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: decimal from the string, never from the float<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>from decimal import Decimal\nimport json, requests\n\nresp = requests.post(\n    \"https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status\",\n    headers={\"apikey\": API_KEY},\n    data={\"userid\": USERID, \"method\": \"getDlr\",\n          \"fromdate\": \"2026-09-01\", \"todate\": \"2026-09-30\",\n          \"pageLimit\": \"500\", \"output\": \"json\"},\n)\n\npayload = json.loads(resp.text, parse_float=Decimal, parse_int=Decimal)\nenv = payload&#91;\"response\"]\nif env.get(\"status\") != \"success\":\n    raise RuntimeError(env.get(\"msg\", \"unknown error\"))\n\ntotal = Decimal(\"0\")\nfor row in env&#91;\"reports_dlrList\"]:\n    total += Decimal(row.get(\"amount\", 0)) * Decimal(row.get(\"cost\", 1))\n\nprint(f\"Usage subtotal: {total}\")\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>parse_float=Decimal<\/code> is the whole trick. It intercepts the unquoted <code>amount<\/code> at parse time and builds the decimal from the literal text in the response body, before Python ever constructs a float. Without it, <code>Decimal(row[\"amount\"])<\/code> receives a float that has already lost precision, and converting a damaged value to an exact type preserves the damage.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: protect the long integers before parsing<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>const params = new URLSearchParams({\n  userid: process.env.SGC_USERID,\n  wabaNumber: process.env.SGC_WABA,\n  fromDate: '2026-09-01 00:00:00',\n  toDate: '2026-09-30 23:59:59',\n  pageLimit: '500',\n  startCursor: '1',\n  output: 'json',\n});\n\nconst res = await fetch('https:\/\/unify.smsgateway.center\/WAApi\/report', {\n  method: 'POST',\n  headers: {\n    apikey: process.env.SGC_API_KEY,\n    'content-type': 'application\/x-www-form-urlencoded',\n  },\n  body: params,\n});\n\nconst raw = await res.text();\nconst safe = raw.replace(\/:\\s*(\\d{16,})(?=\\s*&#91;,}])\/g, ': \"$1\"');\nconst payload = JSON.parse(safe);\n\nif (payload.status !== 'success') {\n  throw new Error(payload.msg ?? 'unknown error');\n}\n\nfor (const row of payload.data.records) {\n  \/\/ uuId, mobileNo and wabaNumber are now strings and intact.\n  await upsertCostRow({\n    channel: 'whatsapp',\n    providerUuid: row.uuId,\n    direction: 'outbound',\n    billingModel: row.billingModel,\n    unitAmount: null,          \/\/ WhatsApp outbound carries no per-message cost\n    unitCount: 1,\n  });\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The regex runs on the raw text before <code>JSON.parse<\/code> sees it, which is the only point at which the nineteen digit identifiers are still intact. Note <code>unitAmount<\/code> is explicitly null rather than zero: a zero would silently claim the message was free, and null correctly says the figure is not available from this source.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Go: two-stage parse so the error responses survive<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>type envelope struct {\n\tStatus string          `json:\"status\"`\n\tMsg    string          `json:\"msg\"`\n\tData   json.RawMessage `json:\"data\"`\n}\n\ntype ratePlan struct {\n\tDefaultCurrency string `json:\"defaultCurrency\"`\n\tRates           &#91;]struct {\n\t\tMCC      string `json:\"mcc\"`\n\t\tMNC      string `json:\"mnc\"`\n\t\tCountry  string `json:\"country\"`\n\t\tOperator string `json:\"operator\"`\n\t\tRate     string `json:\"rate\"`\n\t\tDLTRate  string `json:\"dltRate\"`\n\t} `json:\"rates\"`\n}\n\nfunc readRatePlan(body &#91;]byte) (*ratePlan, error) {\n\tvar outer struct {\n\t\tResponse envelope `json:\"response\"`\n\t}\n\tif err := json.Unmarshal(body, &amp;outer); err != nil {\n\t\treturn nil, err\n\t}\n\tif outer.Response.Status != \"success\" {\n\t\treturn nil, fmt.Errorf(\"rate plan: %s\", outer.Response.Msg)\n\t}\n\tvar plan ratePlan\n\tif err := json.Unmarshal(outer.Response.Data, &amp;plan); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &amp;plan, nil\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Data<\/code> is a <code>json.RawMessage<\/code>, so it stays unparsed until after the status check. On the error response, where <code>data<\/code> is absent entirely, the field is simply the zero value and nothing fails; the function returns the platform&#8217;s own message. Bind the strict type first and the error path throws a parse failure instead, hiding the reason.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both rate strings stay strings all the way into whatever decimal library you use. There is no point in the pipeline where converting them to <code>float64<\/code> is the right move.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decimal-handling\" class=\"wp-block-heading\">Decimal Handling and Rounding Strategy<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Four channels publish cost at three different scales, and the reason to care is that somebody will eventually ask you to account for a single paisa.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pick one internal precision and convert at the boundary. Six decimal places is a comfortable choice: it holds the four that RCS and the rate card publish, it holds the two that Telegram and the WhatsApp inbox publish, and it leaves headroom for an intermediate multiplication on SMS without a premature round.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Round exactly once, at presentation, and never during accumulation. The failure mode is specific and common: rounding each row to two decimals before summing produces a total that can differ from the correctly rounded sum of exact rows by up to half a paisa per row, which on a hundred thousand rows is a number large enough to start a meeting. Accumulate at full precision and round the final figure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use banker&#8217;s rounding, or do not, but decide deliberately and write the decision next to the code. Half-up and half-even give different answers on exactly the values that appear most often in per-message pricing, where trailing fives are common because rates are quoted in fractions of a paisa. Whichever you pick, the invoice was produced with some rule, and matching it exactly is more important than picking the theoretically better one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Never compare two cost figures with string equality. <code>\"0.50\"<\/code> and <code>\"0.5000\"<\/code> are the same amount and different strings, and this platform will hand you both forms for the same concept on different channels. Compare after conversion to your decimal type.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Treat currency as a column rather than an assumption. <code>rateplan\/read<\/code> returns <code>defaultCurrency<\/code> along with a symbol and a position, and the sample in the documentation returns <code>USD<\/code> rather than <code>INR<\/code>. An account can be denominated in something other than what you expect, so store the code on every row and refuse to sum across differing values rather than silently producing a meaningless total.<\/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 Produce a Wrong Total<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Treating SMS <code>cost<\/code> as money.<\/strong> It is the part count. The money field is <code>amount<\/code>, and it prices one part.<\/li>\n\n\n\n<li><strong>Summing only delivered rows.<\/strong> Billing fires at submission, so failed and rejected rows are billed. Filtering them out guarantees a shortfall that scales with your failure rate.<\/li>\n\n\n\n<li><strong>Slicing the period on delivery time.<\/strong> Use <code>submitTime<\/code>. A row that crosses midnight at the end of a period lands on the wrong invoice and puts two months out in opposite directions.<\/li>\n\n\n\n<li><strong>Omitting the WhatsApp inbox.<\/strong> Inbound WhatsApp is billable and carries <code>charges<\/code> per row. Outbound-only totals under-count exactly one channel, which makes the discrepancy look like a WhatsApp pricing problem rather than a missing query.<\/li>\n\n\n\n<li><strong>Expecting a per-message cost on the WhatsApp report row.<\/strong> There is none. Aggregate cost comes from the analytics endpoint grouped by <code>billable<\/code>.<\/li>\n\n\n\n<li><strong>Parsing cost fields as floats.<\/strong> Two of them arrive as unquoted JSON numbers and are already floats before your code runs. Intercept at parse time or re-read the raw body.<\/li>\n\n\n\n<li><strong>Letting <code>JSON.parse<\/code> touch a nineteen digit identifier.<\/strong> Node silently rounds anything above <code>Number.MAX_SAFE_INTEGER<\/code>, and the WhatsApp report emits <code>uuId<\/code>, <code>mobileNo<\/code> and <code>wabaNumber<\/code> unquoted.<\/li>\n\n\n\n<li><strong>Branching on <code>code<\/code> or <code>statusCode<\/code>.<\/strong> They differ in name and JSON type between endpoints that sit in the same namespace. Branch on the top-level <code>status<\/code> string.<\/li>\n\n\n\n<li><strong>Pricing everything at <code>rate<\/code> in India.<\/strong> DLT-registered sends are priced at <code>dltRate<\/code>. The discriminator is a populated <code>dltTemplateId<\/code> on the message row.<\/li>\n\n\n\n<li><strong>Reconciling against the invoice total rather than the usage subtotal.<\/strong> Subscription, seats, conversation charges and taxes are invoice lines with no API representation, and chasing them through the API is time spent looking for something that is not there.<\/li>\n<\/ol>\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\">Which Source to Trust When Two Disagree<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Question<\/th><th>Trust this<\/th><th>Not this<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>What did one SMS cost?<\/td><td><code>amount * cost<\/code> on the delivery row<\/td><td><code>rate<\/code> from the rate card<\/td><td>The row records what was charged; the card records what should be charged<\/td><\/tr><tr><td>What did one WhatsApp send cost?<\/td><td>The analytics aggregate for its <code>billingModel<\/code> group<\/td><td>Any per-message figure<\/td><td>No per-message cost field exists on the outbound row<\/td><\/tr><tr><td>What did one inbound WhatsApp message cost?<\/td><td><code>charges<\/code> on the inbox row<\/td><td>Zero<\/td><td>Inbound is billable on this channel<\/td><\/tr><tr><td>Which rate applies to an Indian SMS?<\/td><td><code>dltRate<\/code> when <code>dltTemplateId<\/code> is populated, else <code>rate<\/code><\/td><td>Whichever is lower<\/td><td>The selector is on the message, not on the account<\/td><\/tr><tr><td>How many credits did a period consume?<\/td><td>Sum of <code>DEBIT<\/code> rows in the credit ledger<\/td><td>Row count of messages sent<\/td><td>Multi-part messages consume more than one credit each<\/td><\/tr><tr><td>What is my remaining balance?<\/td><td><code>smsBalance<\/code> for SMS only<\/td><td>Any single figure for all channels<\/td><td>No other balance is exposed by the API<\/td><\/tr><tr><td>Was a message billed?<\/td><td>Whether it was submitted<\/td><td>Whether it was delivered<\/td><td>Billing fires at submission<\/td><\/tr><tr><td>Which figure goes on the report?<\/td><td>The platform&#8217;s reported figure<\/td><td>Your calculated expectation<\/td><td>Your figure is the question, theirs is the answer being questioned<\/td><\/tr><tr><td>Does the invoice total match?<\/td><td>The usage subtotal against the usage section<\/td><td>The bottom line<\/td><td>Non-usage lines are not in the API<\/td><\/tr><tr><td>Two rows share an identifier, which is real?<\/td><td>Both, if <code>direction<\/code> differs<\/td><td>Either alone<\/td><td>Inbound and outbound identifier namespaces are unrelated<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">Production Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ingestion<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Rate card snapshotted daily into a local table keyed on read date<\/li>\n\n\n\n<li>[ ] Delivery reports pulled per channel on a closed, overlapping, past-ending window sliced on submit time<\/li>\n\n\n\n<li>[ ] WhatsApp inbox pulled over the identical window with <code>direction<\/code> set to inbound<\/li>\n\n\n\n<li>[ ] Paging runs to exhaustion using each channel&#8217;s own mechanism rather than stopping on a short page<\/li>\n\n\n\n<li>[ ] Every write is an upsert on <code>(channel, provider_uuid, direction)<\/code> so re-runs are safe<\/li>\n\n\n\n<li>[ ] Raw cost field stored verbatim alongside the parsed value<\/li>\n\n\n\n<li>[ ] Node ingestion rewrites long integers to strings before <code>JSON.parse<\/code><\/li>\n\n\n\n<li>[ ] Python ingestion parses with <code>parse_float=Decimal<\/code><\/li>\n\n\n\n<li>[ ] Every parser branches on the top-level <code>status<\/code> string only<\/li>\n\n\n\n<li>[ ] Payload binding happens after the status check, so error responses do not throw<\/li>\n\n\n\n<li>[ ] Credentials travel in the <code>apikey<\/code> header, never in a query string<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Calculation<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] <code>unit_amount<\/code> and <code>unit_count<\/code> stored separately, line total generated<\/li>\n\n\n\n<li>[ ] SMS writes <code>amount<\/code> to <code>unit_amount<\/code> and <code>cost<\/code> to <code>unit_count<\/code><\/li>\n\n\n\n<li>[ ] RCS, Telegram and WhatsApp inbox write to <code>unit_amount<\/code> with <code>unit_count<\/code> at one<\/li>\n\n\n\n<li>[ ] WhatsApp outbound writes a null <code>unit_amount<\/code>, never a zero<\/li>\n\n\n\n<li>[ ] All cost columns are a decimal type with at least six places, never a float<\/li>\n\n\n\n<li>[ ] Rate selection reads <code>dltRate<\/code> when <code>dlt_template_id<\/code> is populated<\/li>\n\n\n\n<li>[ ] Route matching normalises country and operator through a lookup table<\/li>\n\n\n\n<li>[ ] Rounding happens once, at presentation, with a documented rule<\/li>\n\n\n\n<li>[ ] Currency code stored per row, and sums across differing codes are refused<\/li>\n\n\n\n<li>[ ] Rows that are not final are excluded from the period aggregate<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reporting and controls<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Output is a usage subtotal, presented separately from configured non-usage lines<\/li>\n\n\n\n<li>[ ] Every group reports three figures: reported, expected, variance<\/li>\n\n\n\n<li>[ ] Credit ledger reconciled separately as integer arithmetic against summed part counts<\/li>\n\n\n\n<li>[ ] WhatsApp groups cross-checked against analytics with <code>groupBy=billable<\/code><\/li>\n\n\n\n<li>[ ] Variance above a configured threshold raises an alert rather than appearing only in a report<\/li>\n\n\n\n<li>[ ] Account expiry date monitored, since credit carry-forward depends on renewal within the due date<\/li>\n\n\n\n<li>[ ] Rate card change between two snapshots raises a notification<\/li>\n\n\n\n<li>[ ] Error codes 486 and 489 on the rate plan detected once at startup and surfaced, not retried on a loop<\/li>\n\n\n\n<li>[ ] Reconciliation runs are themselves idempotent and safe to re-run for a closed period<\/li>\n\n\n\n<li>[ ] A dated archive of each run&#8217;s output is retained for audit<\/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 behaviour in this area is not pinned down by anything you can read. Each item below gives the choice that stays correct whichever way the behaviour turns out, 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 the credit ledger and the currency ledger separately, and never derive one from the other.<\/strong> No response anywhere carries both a credit movement and its currency equivalent on the same row. Whether the platform computes one from the other internally, or maintains them independently, is not something you want to discover during a dispute. Two independent reconciliations, each balancing on its own terms, will tell you which one is wrong. A single blended figure will not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Snapshot the rate card daily from the first day, not from the day you need history.<\/strong> The rate card is returned as it stands right now, with no effective date and no version. Pricing March&#8217;s messages against September&#8217;s card is wrong regardless of whether rates actually changed, and you cannot tell the difference after the fact without a snapshot. A daily row costs nothing and converts an unanswerable question into a join.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Treat a <code>dltRate<\/code> of zero on a specific route as unknown, not as free.<\/strong> The documented sample shows the default route carrying a non-zero <code>dltRate<\/code> while the individual India operator rows carry <code>\"0.0000\"<\/code>. Pricing an Indian DLT send at zero because its operator row says zero will produce a total obviously below the invoice. Fall back to the default entry&#8217;s <code>dltRate<\/code> when a route-specific one is zero, record which rule you applied on the row, and let the variance report tell you whether the fallback was right.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Store <code>unit_amount<\/code> as null rather than zero wherever the source does not provide one.<\/strong> The WhatsApp outbound row has no cost field at all. A zero in that column is a positive claim that the message was free, and it will sum into a total that looks complete and is not. A null is an honest absence, it excludes the row from a sum that would otherwise be silently wrong, and it makes the gap visible in a count.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Page to exhaustion on the record count, not on a short page.<\/strong> The reports and inboxes sort newest first over a date range and offer page and limit rather than a cursor on several surfaces. Whether a page shorter than the limit means the end of the set, or a transient condition in a shifting result set, is not something to infer at three in the morning. Use the envelope&#8217;s own total record count as the loop condition, and use a closed, past-ending window so the set cannot grow underneath you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Make every ingestion run idempotent rather than reasoning about boundaries.<\/strong> Overlap each polling window by several minutes at both ends and let a unique key absorb the duplicates. This is correct whether or not the boundary is inclusive, whether or not timestamps are recorded in the timezone you assume, and whether or not a row can be revised after first appearing. Reasoning about which of those is true is work; overlapping is one line of configuration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Exclude non-final rows from the period aggregate and reprocess them next run.<\/strong> Telegram states finality directly through <code>isFinal<\/code>; the others do not. Whether a cost figure on a non-terminal row can change before the row settles is not documented. Aggregating only settled rows is correct either way, and a small carried-forward tail is far easier to explain than a total that moved after you published it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Record the rule you applied whenever you chose between two possible rates.<\/strong> Route matching on operator names is a string join against values that drift as networks merge and rebrand. Store the rule and the inputs on the row, not just the output. When a variance appears on one route, the difference between a five minute answer and a day of archaeology is whether the row remembers why it was priced the way it was.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Compare cost figures only after conversion to a decimal type.<\/strong> The same amount arrives as <code>\"0.50\"<\/code> on one channel and <code>\"0.0000\"<\/code> scaled to four places on another, and as an unquoted number on two more. Any equality check that touches the string form is correct only by accident and fails the first time a trailing zero differs. Convert, then compare, and the question of what scale a given endpoint uses stops mattering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Present a usage subtotal and configure the rest explicitly.<\/strong> Several invoice lines have no API representation, and how they are computed is a commercial matter rather than a technical one. A reconciliation that reports the usage lines it measured, and lists the lines it did not, is correct and useful whatever those other lines turn out to contain. One that tries to reach the invoice total from API data alone is wrong by construction.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Alert on a variance proportional to volume, separately from a flat variance.<\/strong> These two shapes have different causes. A variance that scales with the number of rows is almost always a per-row rule that is wrong, such as a missing part-count multiplication. A variance that stays roughly constant regardless of volume is almost always a fixed line you have not modelled. Splitting the alert means the first person to look already knows which half of the system to open.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Keep the raw response bytes for every cost figure you store.<\/strong> Whatever the platform&#8217;s internal rounding, encoding or scaling rules turn out to be, the literal text it sent is the ground truth of what you were told. Storing it makes every future question about a figure answerable from your own database, which is worth more than any single piece of documentation you might otherwise be waiting on.<\/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>Why does my delivered-only total come in below the invoice every month?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because billing fires at submission rather than delivery. The published terms state that the rate is deducted while sending and that credits are non-refundable once the message is successfully submitted to the operator. A failed message is a billed message, so a sum restricted to delivered rows is short by your failure rate. Sum over submitted rows and include every delivery status.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is the <code>cost<\/code> field on an SMS delivery row the amount charged?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. <code>cost<\/code> is the number of parts the message was split into, an unquoted integer. <code>amount<\/code> is the price of one part, an unquoted float. The line total is the two multiplied. For single-part messages they coincide with the naive reading, which is why the mistake survives testing and shows up in production once Unicode or longer copy enters the traffic mix.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I get the cost of a single WhatsApp message I sent?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You cannot, from the delivery report. The <code>WAApi\/report<\/code> row carries no cost field. Outbound WhatsApp cost is available from <code>rest\/wa\/v1\/analytics<\/code> with <code>action=delivery<\/code>, as an <code>amount<\/code> aggregated over whatever grouping you request. For invoice work use <code>groupBy=billable<\/code>, which produces the same categories the invoice is organised by.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Then why does the WhatsApp inbox have a <code>charges<\/code> field?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because WhatsApp is the only one of the four channels where receiving a message is billable. Each inbound row in <code>rest\/wa\/v1\/inbox<\/code> carries its own <code>charges<\/code> value as a quoted string with two decimals. The result is that on WhatsApp you can price a received message individually but not a sent one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between <code>rate<\/code> and <code>dltRate<\/code> on the rate plan?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rate<\/code> is the standard rate for that route. <code>dltRate<\/code> applies to India DLT-registered templates. Both are quoted strings with four decimal places. The selector is on the message rather than on the account: a delivery report row carrying a populated <code>dltTemplateId<\/code> was a DLT-registered send and is priced at <code>dltRate<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I read my WhatsApp or RCS balance through the API?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. <code>SMSApi\/account\/readstatus<\/code> returns <code>smsBalance<\/code> and that is the only balance field in the developer API. There is no WhatsApp, RCS or Telegram equivalent on that endpoint or any other. Monitor the SMS balance through the API and the remaining channels through the portal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I pull the credit ledger for a specific month?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The endpoint accepts credentials and <code>output<\/code> and nothing else, so filtering happens on your side. Pull <code>SMSApi\/account\/readcredithistory<\/code> on a schedule, upsert the rows into your own table keyed on the <code>id<\/code> field, and query your copy using the quoted <code>addedTime<\/code> epoch on each row. That also preserves history independently of the platform&#8217;s retention.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does <code>count<\/code> mean something different on every endpoint?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because it does. On <code>readstatus<\/code> it is <code>4<\/code>, matching the four keys inside the <code>account<\/code> object. On <code>readcredithistory<\/code> it is the number of rows. On <code>rateplan\/read<\/code> it is the number of rate entries. On the SMS delivery report it is an object containing <code>total<\/code> and <code>current<\/code>, and on the WhatsApp report it is a nested plural <code>counts<\/code> object. Read the paging figure each endpoint actually documents and do not share a helper across them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I branch on the <code>code<\/code> field to detect success?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do not. <code>code<\/code> is an unquoted number on <code>rateplan\/read<\/code> and a quoted string on both <code>account<\/code> endpoints, which sit in the same namespace. Other families use <code>statusCode<\/code> instead, sometimes quoted and sometimes not. The top-level <code>status<\/code> string is the only field with a consistent name, type and meaning across every endpoint, so branch on that and nothing else.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>My deserialiser throws on the rate plan error response. Why?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Because <code>data<\/code> is absent entirely on error rather than present and empty. A model that requires the key will fail on precisely the response that explains the problem. Parse into a loose envelope first, check <code>status<\/code>, and bind the typed payload only on success. The same two-stage shape also handles the families that return an empty array where an object was expected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should my cost columns be <code>FLOAT<\/code> or <code>NUMERIC<\/code>?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>NUMERIC<\/code>, or the equivalent exact decimal type in your database, with at least six decimal places. Two of the cost fields arrive as unquoted JSON numbers and are already binary floats before your code sees them, so intercept them at parse time. Adding a float to a float across a hundred thousand rows produces a total that will not match a decimal sum of the same data, and the difference always surfaces on the report somebody prints.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>My reconciliation is out by a constant amount regardless of volume. Where should I look?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">At the lines that are not in the API. Platform subscription, agent seats beyond the plan allowance, conversation charges and taxes appear on the invoice and in no endpoint response. A variance that holds steady as volume changes is almost never a per-message pricing problem. A variance that scales with volume is, and the most common cause of that shape is a missing part-count multiplication on SMS.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle a rate change that happened mid-period?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">By having snapshotted the card. <code>rateplan\/read<\/code> returns the current card with no effective date and no history, so once a rate changes the previous one is unrecoverable from the API. Snapshot daily into your own table keyed on read date, and price each message against the snapshot current at its <code>submitTime<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does multi-channel fallback complicate the reconciliation?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, and in a predictable way. A single logical notification that falls back from RCS to SMS produces one row on each channel, and both can be billable. Count rows per channel rather than counting intended notifications, and build any business-level figure by aggregating upward from the rows. An RCS <code>globalErrorCode<\/code> of 5007 is the canonical fallback trigger and a good row to inspect when a fallback-enabled account shows more billed rows than sends.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Reconciling a Multi-Channel Messaging Invoice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Reconciling a multi-channel messaging invoice is easier when the rate card, the credit ledger and the per-message reports all come from one platform with one set of credentials. If you are running SMS, WhatsApp, RCS and Telegram across several providers and spending your month-end joining exports, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to us about consolidating onto a single gateway<\/a> with a documented API for every one of those surfaces.<\/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\/receiving-messages-four-channel-inbox-contracts\/\">Receiving Messages on Four Channels: Inbox API Contracts Compared<\/a><\/li>\n\n\n\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<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>Your wallet moves in credits, your delivery reports report currency, and no endpoint converts one into the other for you. Here is the complete cost contract for SMS, RCS, Telegram and WhatsApp, and a reconciliation job that balances to the rupee.<\/p>\n","protected":false},"author":118,"featured_media":3032,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2272,2275,2016,2276,2273,2274,811,481,2240,632],"class_list":["post-3031","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-billing-reconciliation","tag-credit-history","tag-delivery-reports","tag-finance-engineering","tag-messaging-cost","tag-rate-plan-api","tag-rcs-messaging","tag-sms-api","tag-telegram-api","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3031","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=3031"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3031\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3032"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3031"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3031"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3031"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}