
Table of Contents
- The Short Answer
- TL;DR
- The Four Cost Representations at a Glance
- Why the Wallet and the Report Disagree
- The Rate Plan API, the Only Bridge You Get
- SMS: the Line Total Is a Multiplication You Perform
- RCS: Four Decimals, Two of Which Carry Information
- Telegram: Two Decimals and No Published Rate Card
- WhatsApp: Aggregate Outbound, Per Message Inbound
- Billing Fires at Submission, Not at Delivery
- The Account Endpoints and What They Leave Out
- Envelope and Type Differences Across the Billing Surfaces
- The Invoice Lines That No Endpoint Returns
- A Reconciliation Schema That Survives All Four Channels
- The Reconciliation Algorithm, Step by Step
- Four Language Samples, Four Different Traps
- Decimal Handling and Rounding Strategy
- Ten Mistakes That Produce a Wrong Total
- Which Source to Trust When Two Disagree
- Production Checklist
- Unspecified Behaviour and How to Code Around It
- FAQs
The Short Answer
Four channels report cost four different ways, and only three of them will cost a single message for you. SMS returns amount as an unquoted float that is the price of one part, alongside cost as an unquoted integer that is the part count, so the line total is a multiplication you perform yourself. RCS returns amount as a quoted string with four decimals, already whole. Telegram returns charges 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 amount from the analytics endpoint, grouped by billingModel. The one place WhatsApp does cost a single message is the inbox, where each inbound row carries charges. So on the only channel that bills you for receiving, receiving is the only direction you can price message by message.
Underneath all of that sits a unit mismatch. Your wallet is denominated in credits, integers, visible through SMSApi/account/readcredithistory. Your delivery reports are denominated in currency. The only endpoint that converts between them is SMSApi/rateplan/read, it is read-only, and it publishes two rates for every route, rate and dltRate, 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.
TL;DR
- Cost lives in four different shapes. SMS
amount(unquoted float, per part) pluscost(unquoted integer, part count). RCSamount(quoted string, four decimals, whole message). Telegramcharges(quoted string, two decimals, whole message). WhatsApp outbound: nothing per message, only an aggregateamountfrom analytics. WhatsApp inbound:chargesper row. - SMS is the only channel where the row does not contain the line total. Multiply
amountbycost. Every other channel hands you the finished figure. - WhatsApp is the only channel that bills inbound. A cost model built from outbound data alone under-counts WhatsApp and only WhatsApp. Sum the inbox
chargesfor the same window and add it. billingModelis the invoice join key. 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 analyticsgroupByvalue.- Billing fires at submission. 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.
- The wallet speaks credits, the reports speak currency.
account/readcredithistoryreturns integer credit movements as quoted strings. Nothing in the delivery reports is denominated in credits.rateplan/readis the only published bridge. - The rate plan publishes two rates per route.
rateanddltRate, both quoted strings with four decimals. In India a DLT-registered send is priced differently from a non-DLT send on the same route. account/readstatusexposes exactly one balance,smsBalance. There is no WhatsApp, RCS or Telegram balance field anywhere in the developer API.account/readcredithistoryaccepts no date range and no paging. Period-scoping the credit ledger is client-side work.- Rounding is not portable. 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.
- Parse every cost as a decimal, never as a float. 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.
- Some invoice lines are not in the API at all. 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.
The Four Cost Representations at a Glance
Before any code, the shape of the problem. This table is the whole article compressed, and it is the reason a single parseCost() helper cannot be shared across channels.
| Channel | Where cost lives | Field | JSON type | Decimals in sample | Scope of the figure |
|---|---|---|---|---|---|
| SMS | SMSApi/reports/status row | amount | unquoted number | 0.125 | One part, not one message |
| SMS | SMSApi/reports/status row | cost | unquoted number | 1 | Part count, not currency |
| RCS | rest/rcs/v1/dlr row | amount | quoted string | "0.0000" | Whole message |
| Telegram | rest/tg/v1/dlr row | charges | quoted string | "0.50" | Whole message |
| WhatsApp outbound | WAApi/report row | none | not present | not present | No per-message cost is returned |
| WhatsApp outbound | rest/wa/v1/analytics delivery row | amount | unquoted number | 45.5 | Aggregate over a group, not one message |
| WhatsApp inbound | rest/wa/v1/inbox row | charges | quoted string | "0.00" | One inbound message |
| Account ledger | SMSApi/account/readcredithistory row | credits | quoted string | "10" | Credits, not currency |
| Rate card | SMSApi/rateplan/read rate object | rate, dltRate | quoted strings | "0.0050" | Currency per message, per route |
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.
Why the Wallet and the Report Disagree
There are two ledgers on this platform and they are denominated in different units.
The first is the credit ledger. SMSApi/account/readcredithistory returns movements against your wallet, and every one of them is an integer count of message credits:
{
"response": {
"api": "account",
"action": "readcredithistory",
"status": "success",
"msg": "success",
"code": "200",
"count": 1,
"historyList": [
{
"history": {
"id": "307",
"creditsBefore": "0",
"credits": "10",
"creditsAfter": "10",
"product": "SMS",
"type": "CREDIT",
"transactionType": "1",
"addedTime": "1562969637101",
"creditComments": "test credits for testing"
}
}
]
}
}
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 historyList[i].history, not at historyList[i]. And product is "SMS", which is the field that makes this ledger channel-aware. type carries "CREDIT" or "DEBIT" and tells you the direction of the movement, while transactionType carries "1" and distinguishes categories of movement such as a purchase against an adjustment.
The second is the currency ledger, which is what the delivery reports give you. Nothing in any delivery report is denominated in credits. amount and charges are money.
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.
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.
The Rate Plan API, the Only Bridge You Get
SMSApi/rateplan/read accepts POST or GET, takes nothing but your credentials and an optional output, 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.
curl --location 'https://unify.smsgateway.center/SMSApi/rateplan/read' \
--header 'apikey: YOUR_API_KEY' \
--form 'userid="your_username"' \
--form 'output="json"'
Lead with the apikey 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, userid 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.
The response carries a rates array whose shape depends on one boolean:
{
"response": {
"api": "rateplan",
"action": "read",
"status": "success",
"msg": "Rate plan retrieved successfully",
"code": 200,
"count": 1,
"data": {
"mccMncRoutingEnabled": false,
"defaultCurrency": "USD",
"currencySymbol": "$",
"currencyPosition": "left",
"rates": [
{
"mcc": "Default",
"mnc": "Default",
"country": "Default",
"countryCode": "Default",
"operator": "Default",
"rate": "0.0050",
"dltRate": "0.0010",
"sortName": "Default"
}
]
}
}
}
Three things in there change how you build.
rate and dltRate are two different prices for the same route. rate is the standard rate for an SMS on that route. dltRate 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 dltTemplateId was a DLT-registered send. Join on that.
The route key is MCC plus MNC, not the country code. When mccMncRoutingEnabled is true the array returns one object per country and operator pair, and the default entry is always the first element. When it is false you get the default entry and nothing else. The delivery report row gives you country and network, so your join is from network to operator within country, which is a string match and deserves a normalisation table rather than a direct comparison.
Both rates are quoted strings with four decimal places. 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.
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 486, 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 489 with status set to error. 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.
There is one structural detail in the error responses that matters more than it looks. On success the payload sits under data. On error, data is absent entirely, not present and empty:
{
"response": {
"api": "rateplan",
"action": "read",
"status": "error",
"msg": "Rate plan is not active for your account. Please contact administrator for pricing information",
"code": 489
}
}
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 data 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 status string, return early on failure, and bind your typed model only after status says success.
SMS: the Line Total Is a Multiplication You Perform
The SMS delivery report row from SMSApi/reports/status is the only per-message cost surface on the platform that does not hand you the line total.
{
"country": "IN",
"amount": 0.125,
"msgType": "text",
"cost": 1,
"deliveryTime": 1783887217000,
"length": 13,
"channel": "API",
"msgId": "orderCreate",
"cause": "Template Mismatch",
"mobileNo": 9177709xxxxx,
"uuId": "6358884679059916409",
"dltTemplateId": "12011591428xxxxxxx",
"globalErrorCode": 17,
"cursorId": 162438749,
"network": "Idea",
"senderName": "SMSGAT",
"flashMsg": false,
"submitTime": 1783887217389,
"text": "test template",
"status": "FAILED"
}
amount is 0.125 and cost is 1. The field named cost is not money. It is the number of parts the message was split into, and amount is the price of one of those parts. The line total for this row is amount * cost. 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.
Three further things on this row earn their place in a reconciliation.
dltTemplateId is populated, which tells you this send was DLT-registered and therefore priced at dltRate rather than rate. That is your rate selector, and it is per row rather than per account.
network carries "Idea" and country carries "IN", 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.
status is "FAILED", and this row still costs money. Which brings us to the section on when billing fires.
The types on this row are a trap in themselves. amount is an unquoted JSON number, so by the time a standard deserialiser hands it to you it is already an IEEE 754 double and 0.125 happens to be exactly representable while most rates are not. cost is an unquoted integer. mobileNo is unquoted while uuId is quoted, which is the opposite of the WhatsApp report and a reason to never share a row model between the two. flashMsg is a genuine boolean, one of very few on the platform.
For the safe row key and the broader ingestion design, the delivery report ingestion article covers the identity problem in full. From here on, assume those rows are already landing in your database, and focus on the money columns.
RCS: Four Decimals, Two of Which Carry Information
The RCS delivery row from rest/rcs/v1/dlr is simpler in one respect and stranger in another.
{
"uuId": "6226901107781419438",
"botName": "YourBot",
"mobileNo": "919876543210",
"msgId": "Zr6dILs7iZt1pvO",
"amount": "0.0000",
"msgTypeLabel": "RICH",
"directionLabel": "A2P",
"globalErrorCode": 5007,
"deliveryStatus": "FAILED",
"cause": "RCS Not Enabled",
"channelLabel": "API",
"submitTime": 1788421063350,
"deliveryTime": 1788421066059,
"readTime": 0,
"clickedTime": 0
}
amount is a quoted string with four decimal places, and it covers the whole message. RCS has no part concept, so there is no multiplication to perform. One row, one figure, done.
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.
There is also a directionLabel on this row carrying "A2P". RCS supports inbound, through rest/rcs/v1/inbox, 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.
One reconciliation-specific point about globalErrorCode 5007, "RCS Not Enabled". 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.
The full RCS wire contract, including the two API generations and the format parameter split, is covered in the RCS messaging API reference.
Telegram: Two Decimals and No Published Rate Card
The Telegram delivery row from rest/tg/v1/dlr names its cost field differently again.
{
"logId": "501",
"uniqueId": "90001",
"uuId": "1234567890123456789",
"chatId": "123456789",
"msgId": "42",
"msgType": "text",
"charges": "0.50",
"sendMethod": 1,
"globalErrorCode": 0,
"deliveryStatus": "delivered",
"isFinal": 1,
"submitTime": 1712345678901,
"deliveryTime": 1712345681000
}
charges, not amount. A quoted string with two decimal places, covering the whole message. No part count, no multiplication.
Two Telegram-specific properties change how the rows are gathered rather than how they are priced.
sendMethod on the summary endpoint uses 1 for outgoing and 2 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.
isFinal is unique to this channel and it is genuinely useful here. A row with isFinal set to 0 has not reached a terminal delivery state, which means its charges value may not be the last word. Reconcile on rows whose isFinal is 1, and hold the rest for the next run rather than including a figure that may change.
The uuId 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 uuId unquoted. Any JavaScript reconciliation job that consumes both must protect against the unquoted case, which is covered in the WhatsApp section below.
The full Telegram contract, including the chat identifier model and the empty-array error behaviour, is in the Telegram messaging API reference.
WhatsApp: Aggregate Outbound, Per Message Inbound
WhatsApp is the channel that breaks the pattern, and it breaks it in both directions at once.
Outbound. The WAApi/report row returns no cost field. Not a zero, not an empty string; the concept is simply not on the row:
{
"msgType": "text",
"deliveryTime": 1691165382000,
"billingModel": "SIC",
"msgId": "tnNwDCgP1WhfLwn",
"cause": "Read By User",
"readTime": 1691165389000,
"mobileNo": 919xxxxxxxxx,
"uuId": 3917313917152021246,
"wabaNumber": 9170396xxxxx,
"globalErrorCode": "8006",
"cursorId": 18366779,
"submitTime": "1691165380316",
"status": "DELIVERED"
}
What the row does carry is billingModel, 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, billingModel 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.
To get money out of WhatsApp outbound you go to rest/wa/v1/analytics with action=delivery, which returns aggregate rows:
{
"label": "MARKETING",
"billingModel": "MARKETING",
"country": "India",
"countryId": 91,
"waNumber": "9170396xxxxx",
"date": "2026-06-01",
"requested": 500,
"delivered": 420,
"read": 280,
"failed": 12,
"notSent": 3,
"amount": 45.5
}
amount here is an unquoted float and it is an aggregate over the group, not the price of one message. The grouping is controlled by groupBy, which accepts billable, date, waNumber and country. For invoice reconciliation, groupBy=billable 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.
Two cautions on that endpoint. It is GET only and returns 405 for anything else. And its analyticsList 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 status.
Inbound. The rest/wa/v1/inbox row does carry a per-message cost:
{
"incomingId": "8801",
"wabaNumber": "9170396xxxxx",
"mobileNo": "919876543210",
"waMsgId": "wamid.HBgMOTEXAMPLE",
"messageType": "text",
"profileName": "Rahul",
"message": "Hi, I need help with my order",
"isReplied": 0,
"timestamp": 1752480000000,
"charges": "0.00",
"agentName": "NA"
}
charges, 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.
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.
One correctness hazard specific to this channel. uuId, mobileNo and wabaNumber on the WAApi/report row are unquoted JSON numbers of up to nineteen digits. 3917313917152021246 exceeds Number.MAX_SAFE_INTEGER, so JSON.parse 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:
const raw = await response.text();
const safe = raw.replace(/:\s*(\d{16,})(?=\s*[,}])/g, ': "$1"');
const payload = JSON.parse(safe);
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 uuId, so the same treatment there is harmless but unnecessary. The Node.js integration tutorial covers the broader class of long-numeric hazards on this platform.
The complete WhatsApp contract, including all five response envelopes, is in the WhatsApp Business API wire contract.
Billing Fires at Submission, Not at Delivery
This single fact invalidates more reconciliation code than any type mismatch.
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 submission, meaning the moment the platform accepts your message and hands it onward. It is not delivery, and it is not a successful delivery receipt.
Three consequences follow, and all three are counter-intuitive enough that somebody on your team will argue with them.
A failed message is a billed message. The SMS row shown above has status set to "FAILED" with cause "Template Mismatch", and it carries a real amount. 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.
Your sum must be over submitted rows. Use submitTime as the period boundary, not deliveryTime. 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.
The denominator for any cost-per-message metric is submitted, not delivered. This is the same denominator trap that breaks delivery-rate dashboards, described in the observability article. 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.
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 testing article covers how to build a suite that does not quietly spend your balance, and the retry strategy article covers which failures are worth paying to retry.
The Account Endpoints and What They Leave Out
Three endpoints under SMSApi/account/ and SMSApi/rateplan/ make up the billing surface. Their gaps shape the reconciliation as much as their contents do.
SMSApi/account/readstatus returns the current account state:
{
"response": {
"api": "account",
"action": "readstatus",
"status": "success",
"msg": "success",
"code": "200",
"count": 4,
"account": {
"expDate": "1620153000000",
"endHour": "-1",
"startHour": "-1",
"smsBalance": "1"
}
}
}
smsBalance 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 whatsappBalance, no rcsBalance and no telegramBalance 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.
Note count here is 4, and there are four keys in the account object. On readcredithistory, count is the number of rows. On rateplan/read it is the number of rate entries. Do not write a shared helper that reads count and assumes it means rows.
expDate 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.
SMSApi/account/readcredithistory returns the credit ledger, and its parameter list is the finding. It documents only credentials and output. There is no fromDate, no toDate, no page and no limit. Whatever the endpoint returns, it returns in one response, and period-scoping is something you do after the fact using the quoted addedTime epoch on each row.
Design around that rather than against it. Pull the ledger on a schedule, insert rows into your own table keyed on the id 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.
SMSApi/rateplan/read is covered in full above. Its relevant property here is that it returns the rate card as it is right now. 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 submitTime. That one habit turns an unanswerable question into a join.
Envelope and Type Differences Across the Billing Surfaces
The billing endpoints are a small family and they still disagree with each other. This table is worth keeping next to your deserialisers.
| Surface | Status code field | Its JSON type | count semantics | Payload key | Payload on error |
|---|---|---|---|---|---|
SMSApi/rateplan/read | code | unquoted number | number of rate entries | data (object) | key absent entirely |
SMSApi/account/readstatus | code | quoted string | number of keys in account | account (object) | no error sample published |
SMSApi/account/readcredithistory | code | quoted string | number of rows | historyList (array) | no error sample published |
SMSApi/reports/status | code | quoted string | object with total and current | reports_dlrList (array) | no error sample published |
rest/rcs/v1/dlr | statusCode | quoted string | totalRecords at envelope level | dlrList (array) | array |
rest/tg/v1/dlr | statusCode | quoted string | totalRecords at envelope level | dlrList (array) | empty array |
WAApi/report | code | unquoted number | counts object, nested, plural | data.records (array) | no error sample published |
rest/wa/v1/analytics | statusCode | quoted string | not applicable | analyticsList (object) | empty array |
rest/wa/v1/inbox | statusCode | quoted string | totalRecords at envelope level | inboxList (array) | array |
Two names for the status code, two JSON types for each of them, five different meanings for a field called count, 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: branch on the top-level status string and nothing else. Never on code, never on statusCode, never by parsing msg or reason. Then bind your typed model only after status reads success.
In a statically typed language that means a two-stage parse. Deserialise into a loose envelope that has a status string and nothing else required, check it, and only then deserialise the payload into the strict model. Jackson, encoding/json with a json.RawMessage, serde_json::Value, and System.Text.Json with a JsonElement 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 Java and Spring Boot article works the pattern through in full.
The Invoice Lines That No Endpoint Returns
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.
Platform subscription. 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.
Conversation charges. The WhatsApp commercial model distinguishes user-initiated and business-initiated conversations, and those are billed separately from the plan. The per-message rows carry billingModel, which tells you the category, but the category-to-price mapping is not published through any endpoint.
Taxes. Published rates are quoted exclusive of tax, and the applicable rate is applied on the invoice. No endpoint returns a tax component.
One-time and optional charges. Setup fees, verification services and similar items are invoice lines with no API representation.
The practical consequence is that your reconciliation should produce a usage subtotal, 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 “usage matches to within two paise, and the following four lines are configured rather than measured” is a report that finance can act on. A report that says “we are 1,247 rupees out” is not.
For the commercial context of what drives the per-message figure in the first place, the bulk SMS pricing article covers the inputs, and the pricing pages for each channel carry the current published slabs.
A Reconciliation Schema That Survives All Four Channels
One table, four channels, and every decision below defended.
CREATE TABLE message_cost (
id BIGSERIAL PRIMARY KEY,
channel TEXT NOT NULL,
provider_uuid TEXT NOT NULL,
direction TEXT NOT NULL,
submit_time TIMESTAMPTZ NOT NULL,
delivery_time TIMESTAMPTZ,
is_final BOOLEAN NOT NULL DEFAULT FALSE,
delivery_status TEXT,
unit_amount NUMERIC(12,6),
unit_count INTEGER NOT NULL DEFAULT 1,
line_total NUMERIC(12,6) GENERATED ALWAYS AS
(COALESCE(unit_amount, 0) * unit_count) STORED,
currency TEXT NOT NULL,
billing_model TEXT,
route_country TEXT,
route_operator TEXT,
dlt_template_id TEXT,
rate_card_date DATE,
raw_cost_field TEXT,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (channel, provider_uuid, direction)
);
CREATE INDEX message_cost_period
ON message_cost (submit_time, channel)
WHERE is_final;
CREATE INDEX message_cost_billing_model
ON message_cost (billing_model, submit_time)
WHERE billing_model IS NOT NULL;
unit_amount and unit_count are separate columns, and line_total is generated. This is what makes one table work for all four channels. On SMS you write amount into unit_amount and cost into unit_count. On RCS, Telegram and the WhatsApp inbox you write the cost field into unit_amount and leave unit_count 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.
NUMERIC(12,6), never a floating point type. Six decimal places comfortably holds the four that RCS and the rate card publish, with room for an intermediate calculation. A DOUBLE PRECISION 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.
provider_uuid is TEXT and the unique key includes direction. These identifiers run to nineteen digits, which overflows a 64-bit signed integer in some of their forms and overflows JavaScript’s safe range in all of them, so they are strings in the database as well as in transit. direction is in the key because WhatsApp produces both inbound and outbound rows and their identifier namespaces are unrelated.
billing_model is nullable and indexed. 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.
rate_card_date records which snapshot priced this row. Combined with a small rate_card_snapshot table keyed on the date you read the card, this turns “which rate applied in March” from an argument into a join. Without it, a rate change mid-period is unreconstructable after the fact.
raw_cost_field keeps the string exactly as it arrived. 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.
is_final gates the period index. Telegram publishes this directly. For the other three channels, derive it: a row is final when its delivery_status is in your configured terminal set. The partial index means your monthly aggregate never has to consider rows that are still moving.
This table sits alongside the outbound message table rather than replacing it; the outbound message table article covers the sibling schema and the three-level identity model that links them.
The Reconciliation Algorithm, Step by Step
Six steps, run once per billing period, idempotent at every stage.
Step one. Snapshot the rate card before you need it. Call rateplan/read 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’s card is unrecoverable once it changes.
Step two. Pull the per-message rows for the period, sliced on submit time. For each channel, query the delivery report with a closed date window whose boundaries are submitTime values, and page to exhaustion using that channel’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 message_cost with an upsert.
Step three. Add the WhatsApp inbox for the same window. Query rest/wa/v1/inbox over the identical date range and write each row with direction set to inbound and its charges value into unit_amount. This is the step that is easiest to forget and the only one whose omission produces a shortfall on exactly one channel.
Step four. Price the rows that need pricing, and check the ones that do not. 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 submit_time, select dltRate when dlt_template_id is populated and rate otherwise, match the route on normalised country and operator, and compare the expected figure to unit_amount. Rows that disagree are the interesting output of the whole job. Record the variance rather than overwriting the reported figure; the platform’s number is the one on the invoice, and yours is the one that asks a question.
Step five. Group by the dimension the invoice uses. For WhatsApp that is billing_model, which maps onto the invoice categories directly and can be cross-checked against rest/wa/v1/analytics with groupBy=billable over the same window. For the other three channels, group by channel and by route. Produce a subtotal per group, computed on line_total, in decimal arithmetic throughout.
Step six. Reconcile the credit ledger separately. Pull account/readcredithistory, filter to the period on addedTime, sum the DEBIT rows per product, and compare against the sum of unit_count 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.
The output of a run is three numbers per group: the platform’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.
Four Language Samples, Four Different Traps
Each sample is short and each demonstrates a hazard specific to that runtime.
PHP: the part count multiplication
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.
<?php
$ch = curl_init('https://unify.smsgateway.center/SMSApi/reports/status');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['apikey: ' . getenv('SGC_API_KEY')],
CURLOPT_POSTFIELDS => http_build_query([
'userid' => getenv('SGC_USERID'),
'method' => 'getDlr',
'fromdate' => '2026-09-01',
'todate' => '2026-09-30',
'pageLimit' => '500',
'output' => 'json',
]),
]);
$body = curl_exec($ch);
curl_close($ch);
$payload = json_decode($body, true);
if (($payload['response']['status'] ?? '') !== 'success') {
throw new RuntimeException($payload['response']['msg'] ?? 'unknown error');
}
$total = '0';
foreach ($payload['response']['reports_dlrList'] as $row) {
$unit = (string) ($row['amount'] ?? '0');
$parts = (string) ($row['cost'] ?? '1');
$total = bcadd($total, bcmul($unit, $parts, 6), 6);
}
echo 'Usage subtotal: ' . $total . PHP_EOL;
bcmul and bcadd keep the arithmetic in decimal string form from start to finish. PHP’s native float would introduce the error at the first addition and compound it across every row.
Python: decimal from the string, never from the float
from decimal import Decimal
import json, requests
resp = requests.post(
"https://unify.smsgateway.center/SMSApi/reports/status",
headers={"apikey": API_KEY},
data={"userid": USERID, "method": "getDlr",
"fromdate": "2026-09-01", "todate": "2026-09-30",
"pageLimit": "500", "output": "json"},
)
payload = json.loads(resp.text, parse_float=Decimal, parse_int=Decimal)
env = payload["response"]
if env.get("status") != "success":
raise RuntimeError(env.get("msg", "unknown error"))
total = Decimal("0")
for row in env["reports_dlrList"]:
total += Decimal(row.get("amount", 0)) * Decimal(row.get("cost", 1))
print(f"Usage subtotal: {total}")
parse_float=Decimal is the whole trick. It intercepts the unquoted amount at parse time and builds the decimal from the literal text in the response body, before Python ever constructs a float. Without it, Decimal(row["amount"]) receives a float that has already lost precision, and converting a damaged value to an exact type preserves the damage.
Node.js: protect the long integers before parsing
const params = new URLSearchParams({
userid: process.env.SGC_USERID,
wabaNumber: process.env.SGC_WABA,
fromDate: '2026-09-01 00:00:00',
toDate: '2026-09-30 23:59:59',
pageLimit: '500',
startCursor: '1',
output: 'json',
});
const res = await fetch('https://unify.smsgateway.center/WAApi/report', {
method: 'POST',
headers: {
apikey: process.env.SGC_API_KEY,
'content-type': 'application/x-www-form-urlencoded',
},
body: params,
});
const raw = await res.text();
const safe = raw.replace(/:\s*(\d{16,})(?=\s*[,}])/g, ': "$1"');
const payload = JSON.parse(safe);
if (payload.status !== 'success') {
throw new Error(payload.msg ?? 'unknown error');
}
for (const row of payload.data.records) {
// uuId, mobileNo and wabaNumber are now strings and intact.
await upsertCostRow({
channel: 'whatsapp',
providerUuid: row.uuId,
direction: 'outbound',
billingModel: row.billingModel,
unitAmount: null, // WhatsApp outbound carries no per-message cost
unitCount: 1,
});
}
The regex runs on the raw text before JSON.parse sees it, which is the only point at which the nineteen digit identifiers are still intact. Note unitAmount 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.
Go: two-stage parse so the error responses survive
type envelope struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
type ratePlan struct {
DefaultCurrency string `json:"defaultCurrency"`
Rates []struct {
MCC string `json:"mcc"`
MNC string `json:"mnc"`
Country string `json:"country"`
Operator string `json:"operator"`
Rate string `json:"rate"`
DLTRate string `json:"dltRate"`
} `json:"rates"`
}
func readRatePlan(body []byte) (*ratePlan, error) {
var outer struct {
Response envelope `json:"response"`
}
if err := json.Unmarshal(body, &outer); err != nil {
return nil, err
}
if outer.Response.Status != "success" {
return nil, fmt.Errorf("rate plan: %s", outer.Response.Msg)
}
var plan ratePlan
if err := json.Unmarshal(outer.Response.Data, &plan); err != nil {
return nil, err
}
return &plan, nil
}
Data is a json.RawMessage, so it stays unparsed until after the status check. On the error response, where data is absent entirely, the field is simply the zero value and nothing fails; the function returns the platform’s own message. Bind the strict type first and the error path throws a parse failure instead, hiding the reason.
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 float64 is the right move.
Decimal Handling and Rounding Strategy
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.
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.
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.
Use banker’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.
Never compare two cost figures with string equality. "0.50" and "0.5000" 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.
Treat currency as a column rather than an assumption. rateplan/read returns defaultCurrency along with a symbol and a position, and the sample in the documentation returns USD rather than INR. 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.
Ten Mistakes That Produce a Wrong Total
- Treating SMS
costas money. It is the part count. The money field isamount, and it prices one part. - Summing only delivered rows. Billing fires at submission, so failed and rejected rows are billed. Filtering them out guarantees a shortfall that scales with your failure rate.
- Slicing the period on delivery time. Use
submitTime. A row that crosses midnight at the end of a period lands on the wrong invoice and puts two months out in opposite directions. - Omitting the WhatsApp inbox. Inbound WhatsApp is billable and carries
chargesper row. Outbound-only totals under-count exactly one channel, which makes the discrepancy look like a WhatsApp pricing problem rather than a missing query. - Expecting a per-message cost on the WhatsApp report row. There is none. Aggregate cost comes from the analytics endpoint grouped by
billable. - Parsing cost fields as floats. 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.
- Letting
JSON.parsetouch a nineteen digit identifier. Node silently rounds anything aboveNumber.MAX_SAFE_INTEGER, and the WhatsApp report emitsuuId,mobileNoandwabaNumberunquoted. - Branching on
codeorstatusCode. They differ in name and JSON type between endpoints that sit in the same namespace. Branch on the top-levelstatusstring. - Pricing everything at
ratein India. DLT-registered sends are priced atdltRate. The discriminator is a populateddltTemplateIdon the message row. - Reconciling against the invoice total rather than the usage subtotal. 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.
Which Source to Trust When Two Disagree
| Question | Trust this | Not this | Why |
|---|---|---|---|
| What did one SMS cost? | amount * cost on the delivery row | rate from the rate card | The row records what was charged; the card records what should be charged |
| What did one WhatsApp send cost? | The analytics aggregate for its billingModel group | Any per-message figure | No per-message cost field exists on the outbound row |
| What did one inbound WhatsApp message cost? | charges on the inbox row | Zero | Inbound is billable on this channel |
| Which rate applies to an Indian SMS? | dltRate when dltTemplateId is populated, else rate | Whichever is lower | The selector is on the message, not on the account |
| How many credits did a period consume? | Sum of DEBIT rows in the credit ledger | Row count of messages sent | Multi-part messages consume more than one credit each |
| What is my remaining balance? | smsBalance for SMS only | Any single figure for all channels | No other balance is exposed by the API |
| Was a message billed? | Whether it was submitted | Whether it was delivered | Billing fires at submission |
| Which figure goes on the report? | The platform’s reported figure | Your calculated expectation | Your figure is the question, theirs is the answer being questioned |
| Does the invoice total match? | The usage subtotal against the usage section | The bottom line | Non-usage lines are not in the API |
| Two rows share an identifier, which is real? | Both, if direction differs | Either alone | Inbound and outbound identifier namespaces are unrelated |
Production Checklist
Ingestion
- [ ] Rate card snapshotted daily into a local table keyed on read date
- [ ] Delivery reports pulled per channel on a closed, overlapping, past-ending window sliced on submit time
- [ ] WhatsApp inbox pulled over the identical window with
directionset to inbound - [ ] Paging runs to exhaustion using each channel’s own mechanism rather than stopping on a short page
- [ ] Every write is an upsert on
(channel, provider_uuid, direction)so re-runs are safe - [ ] Raw cost field stored verbatim alongside the parsed value
- [ ] Node ingestion rewrites long integers to strings before
JSON.parse - [ ] Python ingestion parses with
parse_float=Decimal - [ ] Every parser branches on the top-level
statusstring only - [ ] Payload binding happens after the status check, so error responses do not throw
- [ ] Credentials travel in the
apikeyheader, never in a query string
Calculation
- [ ]
unit_amountandunit_countstored separately, line total generated - [ ] SMS writes
amounttounit_amountandcosttounit_count - [ ] RCS, Telegram and WhatsApp inbox write to
unit_amountwithunit_countat one - [ ] WhatsApp outbound writes a null
unit_amount, never a zero - [ ] All cost columns are a decimal type with at least six places, never a float
- [ ] Rate selection reads
dltRatewhendlt_template_idis populated - [ ] Route matching normalises country and operator through a lookup table
- [ ] Rounding happens once, at presentation, with a documented rule
- [ ] Currency code stored per row, and sums across differing codes are refused
- [ ] Rows that are not final are excluded from the period aggregate
Reporting and controls
- [ ] Output is a usage subtotal, presented separately from configured non-usage lines
- [ ] Every group reports three figures: reported, expected, variance
- [ ] Credit ledger reconciled separately as integer arithmetic against summed part counts
- [ ] WhatsApp groups cross-checked against analytics with
groupBy=billable - [ ] Variance above a configured threshold raises an alert rather than appearing only in a report
- [ ] Account expiry date monitored, since credit carry-forward depends on renewal within the due date
- [ ] Rate card change between two snapshots raises a notification
- [ ] Error codes 486 and 489 on the rate plan detected once at startup and surfaced, not retried on a loop
- [ ] Reconciliation runs are themselves idempotent and safe to re-run for a closed period
- [ ] A dated archive of each run’s output is retained for audit
Unspecified Behaviour and How to Code Around It
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.
One. Reconcile the credit ledger and the currency ledger separately, and never derive one from the other. 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.
Two. Snapshot the rate card daily from the first day, not from the day you need history. The rate card is returned as it stands right now, with no effective date and no version. Pricing March’s messages against September’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.
Three. Treat a dltRate of zero on a specific route as unknown, not as free. The documented sample shows the default route carrying a non-zero dltRate while the individual India operator rows carry "0.0000". 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’s dltRate 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.
Four. Store unit_amount as null rather than zero wherever the source does not provide one. 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.
Five. Page to exhaustion on the record count, not on a short page. 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’s own total record count as the loop condition, and use a closed, past-ending window so the set cannot grow underneath you.
Six. Make every ingestion run idempotent rather than reasoning about boundaries. 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.
Seven. Exclude non-final rows from the period aggregate and reprocess them next run. Telegram states finality directly through isFinal; 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.
Eight. Record the rule you applied whenever you chose between two possible rates. 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.
Nine. Compare cost figures only after conversion to a decimal type. The same amount arrives as "0.50" on one channel and "0.0000" 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.
Ten. Present a usage subtotal and configure the rest explicitly. 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.
Eleven. Alert on a variance proportional to volume, separately from a flat variance. 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.
Twelve. Keep the raw response bytes for every cost figure you store. Whatever the platform’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.
FAQs
Why does my delivered-only total come in below the invoice every month?
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.
Is the cost field on an SMS delivery row the amount charged?
No. cost is the number of parts the message was split into, an unquoted integer. amount 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.
How do I get the cost of a single WhatsApp message I sent?
You cannot, from the delivery report. The WAApi/report row carries no cost field. Outbound WhatsApp cost is available from rest/wa/v1/analytics with action=delivery, as an amount aggregated over whatever grouping you request. For invoice work use groupBy=billable, which produces the same categories the invoice is organised by.
Then why does the WhatsApp inbox have a charges field?
Because WhatsApp is the only one of the four channels where receiving a message is billable. Each inbound row in rest/wa/v1/inbox carries its own charges 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.
What is the difference between rate and dltRate on the rate plan?
rate is the standard rate for that route. dltRate 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 dltTemplateId was a DLT-registered send and is priced at dltRate.
Can I read my WhatsApp or RCS balance through the API?
No. SMSApi/account/readstatus returns smsBalance 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.
How do I pull the credit ledger for a specific month?
The endpoint accepts credentials and output and nothing else, so filtering happens on your side. Pull SMSApi/account/readcredithistory on a schedule, upsert the rows into your own table keyed on the id field, and query your copy using the quoted addedTime epoch on each row. That also preserves history independently of the platform’s retention.
Why does count mean something different on every endpoint?
Because it does. On readstatus it is 4, matching the four keys inside the account object. On readcredithistory it is the number of rows. On rateplan/read it is the number of rate entries. On the SMS delivery report it is an object containing total and current, and on the WhatsApp report it is a nested plural counts object. Read the paging figure each endpoint actually documents and do not share a helper across them.
Can I branch on the code field to detect success?
Do not. code is an unquoted number on rateplan/read and a quoted string on both account endpoints, which sit in the same namespace. Other families use statusCode instead, sometimes quoted and sometimes not. The top-level status string is the only field with a consistent name, type and meaning across every endpoint, so branch on that and nothing else.
My deserialiser throws on the rate plan error response. Why?
Because data 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 status, 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.
Should my cost columns be FLOAT or NUMERIC?
NUMERIC, 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.
My reconciliation is out by a constant amount regardless of volume. Where should I look?
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.
How do I handle a rate change that happened mid-period?
By having snapshotted the card. rateplan/read 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 submitTime.
Does multi-channel fallback complicate the reconciliation?
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 globalErrorCode of 5007 is the canonical fallback trigger and a good row to inspect when a fallback-enabled account shows more billed rows than sends.
Reconciling a Multi-Channel Messaging Invoice
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, talk to us about consolidating onto a single gateway with a documented API for every one of those surfaces.
Recent Articles
- Receiving Messages on Four Channels: Inbox API Contracts Compared
- WhatsApp Business API Wire Contract: Ten Endpoints, Two Base Paths, Five Envelopes
- Telegram Messaging API: Eight Endpoints, the Chat ID Model, and the Error Shape That Breaks Typed Clients
- RCS Messaging API: Complete Reference and Migration Path from SMS
- Delivery Report Ingestion: Building a System of Record, Not a Dashboard