{"id":3133,"date":"2026-11-02T09:03:13","date_gmt":"2026-11-02T03:33:13","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3133"},"modified":"2026-11-02T09:03:16","modified_gmt":"2026-11-02T03:33:16","slug":"lossless-json-parsing-messaging-api-19-digit-ids","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/lossless-json-parsing-messaging-api-19-digit-ids\/","title":{"rendered":"Lossless JSON Parsing for Messaging APIs: 19-Digit IDs, Mixed Types and JSON Inside JSON"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A messaging API can hand you the same transaction ID as a quoted string on one endpoint and a bare 19-digit number on the next. Default JSON parsers in JavaScript, jq and several Java and Go setups round that number silently. Here&#8217;s how to read SMS, WhatsApp, RCS and voice responses in Python, Node.js, Java and shell without losing a digit.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/lossless-json-parsing-messaging-api-19-digit-ids.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/lossless-json-parsing-messaging-api-19-digit-ids-1024x584.webp\" alt=\"Abstract row of nineteen teal tiles passing through a prism, where the last tiles blur into orange while a parallel protected row stays intact\" class=\"wp-image-3134\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/lossless-json-parsing-messaging-api-19-digit-ids-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/lossless-json-parsing-messaging-api-19-digit-ids-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/lossless-json-parsing-messaging-api-19-digit-ids-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/lossless-json-parsing-messaging-api-19-digit-ids.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Nineteen digits in, nineteen digits out: the job of a lossless parser.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#why-it-breaks\">Why a Correct Parser Still Corrupts Your Data<\/a><\/li>\n\n\n\n<li><a href=\"#type-map\">The Same Field, Two Types: A Map of the Responses<\/a><\/li>\n\n\n\n<li><a href=\"#double-precision\">What a Double Does to a 19-Digit ID<\/a><\/li>\n\n\n\n<li><a href=\"#languages\">Language by Language: Where the Digits Go Missing<\/a><\/li>\n\n\n\n<li><a href=\"#python\">Python: Integers Are Safe, the Next Hop Isn&#8217;t<\/a><\/li>\n\n\n\n<li><a href=\"#nodejs\">Node.js: Rescue the Source Text in the Reviver<\/a><\/li>\n\n\n\n<li><a href=\"#java\">Java: Read IDs as Text from the Tree<\/a><\/li>\n\n\n\n<li><a href=\"#jq-shell\">jq and Shell Scripts: Quote Before You Parse<\/a><\/li>\n\n\n\n<li><a href=\"#json-in-json\">JSON Inside JSON: Fields That Hold Strings<\/a><\/li>\n\n\n\n<li><a href=\"#envelope\">Reading the Envelope Without Trusting Its Types<\/a><\/li>\n\n\n\n<li><a href=\"#money\">Money and Rates: Decimal, Never Float<\/a><\/li>\n\n\n\n<li><a href=\"#timestamps\">Timestamps: Numbers, Strings and Formatted Twins<\/a><\/li>\n\n\n\n<li><a href=\"#phones-pins\">Phone Numbers and PINs Are Text<\/a><\/li>\n\n\n\n<li><a href=\"#writing-back\">Writing It Back: Storage, Logs and Outbound JSON<\/a><\/li>\n\n\n\n<li><a href=\"#contract-tests\">A Parsing Contract You Can Test<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix: Which Fix for Which Stack<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Implementation Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Treat every identifier and phone number as text, decide that at one boundary in your code, and decide it by field name rather than by endpoint. Messaging APIs return the same concept with different JSON types: on SMSGatewayCenter, the SMS delivery report quotes <code>uuId<\/code> as <code>\"6358884679059916409\"<\/code> while the WhatsApp report sends <code>uuId<\/code> as a bare number, <code>3917313917152021246<\/code>. A JavaScript <code>JSON.parse<\/code>, jq 1.6, Gson into a <code>Map<\/code>, or Go into an <code>interface{}<\/code> turns that bare number into a double, and a double can&#8217;t hold 19 digits. You get <code>3917313917152021000<\/code> back, with no error.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is small. In Python, the standard <code>json<\/code> module already keeps integers exact; add <code>parse_float=Decimal<\/code> for money. In Node.js 21 and newer, read <code>context.source<\/code> inside a <code>JSON.parse<\/code> reviver. In Java, read the Jackson tree and pull IDs out with <code>bigIntegerValue()<\/code> or <code>textValue()<\/code>. In shell, quote long integers before jq sees them. Then store IDs in <code>TEXT<\/code> columns, money in <code>NUMERIC<\/code>, epoch milliseconds in <code>BIGINT<\/code>, and decode JSON-in-a-string fields exactly once.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Same key, different type.<\/strong> <code>uuId<\/code> is a string on <code>SMSApi\/reports\/status<\/code> and an unquoted number on <code>WAApi\/report<\/code>. <code>mobileNo<\/code> is unquoted on both, but a string on the RCS inbox. <code>code<\/code> is <code>\"200\"<\/code> on one and <code>200<\/code> on the other.<\/li>\n\n\n\n<li><strong>Doubles hold about 15 to 17 significant digits.<\/strong> Integers are exact only up to 9,007,199,254,740,991 (16 digits). Message IDs here are 19 digits. One thousand consecutive IDs near 3.9 x 10^18 collapse into just three distinct doubles.<\/li>\n\n\n\n<li><strong>Python is safe by default for integers;<\/strong> JavaScript, jq 1.6, Gson to <code>Object<\/code> and Go to <code>interface{}<\/code> are not.<\/li>\n\n\n\n<li><strong>Normalise by field name at one boundary.<\/strong> After it, <code>uuId<\/code> is text on every channel, <code>submitTime<\/code> is a 64-bit integer whether it arrived as <code>1783887217389<\/code> or <code>\"1691165380316\"<\/code>, and <code>amount<\/code> is a <code>Decimal<\/code>.<\/li>\n\n\n\n<li><strong>Decode nested JSON strings once.<\/strong> The RCS template list returns <code>data<\/code> as a JSON string. Parse it once, and accept an object too in case it ever arrives decoded.<\/li>\n\n\n\n<li><strong>Read <code>status<\/code>, not <code>code<\/code> or <code>statusCode<\/code>.<\/strong> Their types differ across endpoints, so don&#8217;t branch on them.<\/li>\n\n\n\n<li><strong>Refuse an ID that arrives as a float.<\/strong> It&#8217;s already damaged, and rounding it gives you a plausible wrong number.<\/li>\n\n\n\n<li><strong>Test against the published samples<\/strong> with the masked digits filled in, and assert that the same key has the same type on every channel after normalisation.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-it-breaks\" class=\"wp-block-heading\">Why a Correct Parser Still Corrupts Your Data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing here is a parser bug. JSON itself has one number type with no size limit. <a href=\"https:\/\/www.rfc-editor.org\/info\/rfc8259\/\" target=\"_blank\" rel=\"noopener nofollow\">RFC 8259<\/a> leaves the range and precision up to each implementation and warns that numbers beyond what an IEEE 754 double can represent exactly may not survive the trip between systems. Most JSON libraries map every number to a 64-bit double because that&#8217;s the cheap, common choice. That&#8217;s fine for counts and prices. It&#8217;s not fine for an 18 or 19-digit identifier.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the damage happens in a gap between two correct things: a server that writes a valid 19-digit JSON number, and a client that parses it into a valid double. Neither side throws. The value you get back looks like an ID, has the right length, and is wrong in its last three or four digits.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three things make messaging APIs especially exposed:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>IDs are long and numeric.<\/strong> SMS transaction IDs on the send response look like <code>\"6305583318236810379\"<\/code>. Delivery report <code>uuId<\/code> values look like <code>6358884679059916409<\/code>. Both sit well above the 16-digit safe range.<\/li>\n\n\n\n<li><strong>The same concept travels in different types.<\/strong> One endpoint quotes the ID, another doesn&#8217;t, a third has a spec that types it as an integer. Code that works on the first endpoint quietly breaks on the second.<\/li>\n\n\n\n<li><strong>IDs are join keys.<\/strong> You match delivery reports back to sends, sends back to invoices, and inbound replies back to conversations. A rounded ID doesn&#8217;t throw an exception. It just fails to match, or worse, matches a different message. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">Delivery report ingestion<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">idempotent sending<\/a> both assume the ID you stored is the ID the provider holds.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">There are two more ways a value can change shape on the way in, and they cause the same kind of quiet mismatch: a field whose value is itself a JSON document wrapped in a string, and timestamps or amounts that arrive as a number on one endpoint and a string on the next. Both are covered below.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"type-map\" class=\"wp-block-heading\">The Same Field, Two Types: A Map of the Responses<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s how the same concepts come back across the SMSGatewayCenter surfaces, taken from the published sample responses on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">SMS delivery report<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-reports\/\">WhatsApp reports<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/rcs-inbox\/\">RCS inbox<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/list-rcs-template\/\">RCS template list<\/a> pages, the SMS send response on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">send SMS page<\/a>, and the voice OpenAPI file behind the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/voice-call-api\/\">Voice Call API reference<\/a>.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Concept<\/th><th>SMS send (<code>SMSApi\/send<\/code>)<\/th><th>SMS reports (<code>SMSApi\/reports\/status<\/code>, <code>getDlr<\/code>)<\/th><th>WhatsApp reports (<code>WAApi\/report<\/code>)<\/th><th>RCS (<code>RCSApi\/send<\/code>, <code>rest\/rcs\/v1\/inbox<\/code>)<\/th><th>Voice spec (<code>voice_api.yaml<\/code>)<\/th><\/tr><\/thead><tbody><tr><td>Message ID<\/td><td><code>\"transactionId\": \"6305583318236810379\"<\/code><\/td><td><code>\"uuId\": \"6358884679059916409\"<\/code><\/td><td><code>\"uuId\": 3917313917152021246<\/code><\/td><td><code>\"transactionId\": \"3983571817594375469\"<\/code><\/td><td><code>uuid<\/code> typed <code>integer<\/code><\/td><\/tr><tr><td>Mobile number<\/td><td><code>\"mobile\": \"919999999999\"<\/code><\/td><td><code>\"mobileNo\": 9177709xxxxx<\/code> (bare)<\/td><td><code>\"mobileNo\": 919xxxxxxxxx<\/code> (bare)<\/td><td><code>\"mobileNo\": \"919876543210\"<\/code><\/td><td><code>mobileNo<\/code> typed <code>integer<\/code><\/td><\/tr><tr><td>Envelope code<\/td><td><code>\"statusCode\": \"200\"<\/code><\/td><td><code>\"code\": \"200\"<\/code><\/td><td><code>\"code\": 200<\/code><\/td><td><code>\"statusCode\": \"200\"<\/code><\/td><td><code>statusCode<\/code> <code>\"200\"<\/code> on send<\/td><\/tr><tr><td>Paging cursor<\/td><td>none<\/td><td><code>\"next\": \"9ae9e5e\"<\/code> (string)<\/td><td><code>\"next\": 19382015<\/code> (number)<\/td><td><code>page<\/code> and <code>limit<\/code><\/td><td>none<\/td><\/tr><tr><td>Event time<\/td><td>not in sample<\/td><td><code>\"submitTime\": 1783887217389<\/code><\/td><td><code>\"submitTime\": \"1691165380316\"<\/code><\/td><td><code>\"receivedAt\": 1752480000000<\/code><\/td><td>not stated<\/td><\/tr><tr><td>Money<\/td><td>not in sample<\/td><td><code>\"amount\": 0.125<\/code><\/td><td>none<\/td><td>not in inbox sample<\/td><td>not stated<\/td><\/tr><tr><td>JSON in a string<\/td><td>none<\/td><td>none<\/td><td>none<\/td><td>template list <code>data<\/code>, send <code>customParams<\/code><\/td><td>none<\/td><\/tr><tr><td>Small integer IDs<\/td><td>none<\/td><td><code>\"cursorId\": 162438749<\/code><\/td><td><code>\"cursorId\": 18366779<\/code><\/td><td><code>\"incomingId\": \"451\"<\/code>, <code>\"botId\": 3<\/code><\/td><td><code>libraryId<\/code>, <code>msgId<\/code> typed <code>integer<\/code><\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-json-type-drift-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-json-type-drift-table.svg\" alt=\"Table showing the JSON type of message ID, mobile number, envelope code, paging cursor, event time and nested JSON across SMS send, SMS getDlr, WhatsApp report, RCS and the voice spec, with risky combinations highlighted in orange\" class=\"wp-image-3136\"\/><\/a><figcaption class=\"wp-element-caption\">The same concept in five places. Orange cells are the ones that break a default parser or a naive equality check.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Read across a row and the pattern is plain. <code>uuId<\/code> is a string on SMS reports and a bare number on WhatsApp reports. <code>submitTime<\/code> flips the other way: a number on SMS, a string on WhatsApp. <code>incomingId<\/code> on the RCS inbox is a quoted <code>\"451\"<\/code> while <code>botId<\/code> next to it is a bare <code>3<\/code>. None of this is wrong JSON. It just means you can&#8217;t write one parser per endpoint and expect your stored data to agree with itself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A note on the samples: masked values such as <code>9177709xxxxx<\/code> aren&#8217;t valid JSON as printed. When you copy a sample into a test fixture, swap the <code>x<\/code> characters for digits first, and keep the length the same so the fixture still exercises a 12-digit mobile number.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"double-precision\" class=\"wp-block-heading\">What a Double Does to a 19-Digit ID<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An IEEE 754 double has a 53-bit significand. Every integer up to 2^53, which is 9,007,199,254,740,992, fits exactly. Above that, the gap between representable values grows. Between 2^61 and 2^62 (roughly 2.3 x 10^18 to 4.6 x 10^18) the gap is 512. Between 2^62 and 2^63 it&#8217;s 1,024. Every 19-digit ID in these samples falls in one of those two bands. (<a href=\"https:\/\/en.wikipedia.org\/wiki\/IEEE_754\" target=\"_blank\" rel=\"noopener nofollow\">IEEE 754<\/a> has the background if you want it.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what happens to the real sample IDs. The &#8220;double value&#8221; column is the exact value the double holds; the &#8220;JavaScript prints&#8221; column is what <code>String(Number(id))<\/code> shows, which is the shortest decimal that rounds back to the same double.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Source<\/th><th>Original ID<\/th><th>Exact double value<\/th><th>JavaScript prints<\/th><th>Off by<\/th><\/tr><\/thead><tbody><tr><td><code>WAApi\/report<\/code> <code>uuId<\/code><\/td><td>3917313917152021246<\/td><td>3917313917152020992<\/td><td>3917313917152021000<\/td><td>-254<\/td><\/tr><tr><td><code>getDlr<\/code> <code>uuId<\/code> (row 1)<\/td><td>6358884679059916409<\/td><td>6358884679059916800<\/td><td>6358884679059917000<\/td><td>+391<\/td><\/tr><tr><td><code>getDlr<\/code> <code>uuId<\/code> (row 2)<\/td><td>6626503083053237902<\/td><td>6626503083053238272<\/td><td>6626503083053238000<\/td><td>+370<\/td><\/tr><tr><td><code>SMSApi\/send<\/code> <code>transactionId<\/code><\/td><td>6305583318236810379<\/td><td>6305583318236810240<\/td><td>6305583318236810000<\/td><td>-139<\/td><\/tr><tr><td><code>RCSApi\/send<\/code> <code>transactionId<\/code><\/td><td>3983571817594375469<\/td><td>3983571817594375680<\/td><td>3983571817594375700<\/td><td>+211<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to notice. First, the printed value and the stored value don&#8217;t even agree with each other, so a log line won&#8217;t tell you what your database holds. Second, collisions are real: one thousand consecutive IDs starting at 3917313917152021246 map to just three distinct doubles. If your provider ever hands out IDs that sit close together, two messages can end up with the same stored key.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"languages\" class=\"wp-block-heading\">Language by Language: Where the Digits Go Missing<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Runtime and call<\/th><th>What a 19-digit JSON integer becomes<\/th><th>Lossless option<\/th><\/tr><\/thead><tbody><tr><td>Python <code>json.loads<\/code><\/td><td>Exact <code>int<\/code> (arbitrary precision)<\/td><td>Already safe for integers. Add <code>parse_float=Decimal<\/code> for money.<\/td><\/tr><tr><td>Node.js or browser <code>JSON.parse<\/code><\/td><td>Rounded <code>number<\/code><\/td><td>Reviver with <code>context.source<\/code> (Node 21 and newer), or quote long integers before parsing<\/td><\/tr><tr><td>Java, Jackson <code>readTree<\/code><\/td><td><code>LongNode<\/code> or <code>BigIntegerNode<\/code>, exact<\/td><td>Read IDs with <code>textValue()<\/code> or <code>bigIntegerValue()<\/code>. Don&#8217;t bind them to <code>double<\/code>.<\/td><\/tr><tr><td>Java, Gson into <code>Map<\/code> or <code>Object<\/code><\/td><td><code>Double<\/code> by default<\/td><td>Bind to typed classes with <code>String<\/code> fields, or set a <code>ToNumberPolicy<\/code> such as <code>LAZILY_PARSED_NUMBER<\/code><\/td><\/tr><tr><td>PHP <code>json_decode<\/code><\/td><td><code>int<\/code> if it fits in 64 bits, otherwise <code>float<\/code><\/td><td><code>JSON_BIGINT_AS_STRING<\/code> flag<\/td><\/tr><tr><td>Go <code>encoding\/json<\/code> into <code>interface{}<\/code><\/td><td><code>float64<\/code><\/td><td><code>Decoder.UseNumber()<\/code> to get <code>json.Number<\/code>, or <code>string<\/code> struct fields where the server quotes the value<\/td><\/tr><tr><td>jq 1.6<\/td><td>Rounded<\/td><td>Quote long integers before jq, or use a newer jq and check it (see <a href=\"#jq-shell\">the shell section<\/a>)<\/td><\/tr><tr><td>Spreadsheet import of a CSV export<\/td><td>Cut to 15 significant digits and often shown in scientific notation<\/td><td>Import ID columns as text<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two languages on that list are safe by accident, which is a trap of its own. Python and Jackson keep the digits, so tests written in them pass. Then the same ID goes out to a browser dashboard, a Node service, or a CSV someone opens in a spreadsheet, and it rounds there. That&#8217;s why the fix below is about types at a boundary, not about picking a &#8220;safe&#8221; language.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"python\" class=\"wp-block-heading\">Python: Integers Are Safe, the Next Hop Isn&#8217;t<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Python&#8217;s <code>json<\/code> module parses integers into arbitrary-precision <code>int<\/code>, so <code>3917313917152021246<\/code> comes back intact. Two things still go wrong in Python code:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Floats.<\/strong> By default, <code>0.125<\/code> becomes a binary float. That&#8217;s exact for 0.125 as it happens, but not for most rates. Pass <code>parse_float=Decimal<\/code> and money stays decimal.<\/li>\n\n\n\n<li><strong>The next hop.<\/strong> A Python service that keeps the ID as an <code>int<\/code> and returns it in JSON to a browser has just moved the rounding to the browser. Convert IDs to <code>str<\/code> at the boundary so every consumer downstream sees text.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The module below is the whole boundary. It reads <code>status<\/code> at the right nesting level, binds rows only on success, turns every known ID key into text whichever JSON type it arrived as, accepts epoch milliseconds as a number or a digit string, refuses an ID that&#8217;s already a float, and decodes JSON-in-a-string fields once.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"Lossless parsing helpers for messaging API responses.\"\"\"\nimport json\nfrom decimal import Decimal\n\n# Keys that hold identifiers or phone numbers. They are text, whatever\n# JSON type the server used.\nID_KEYS = {\n    \"uuId\", \"uuid\", \"transactionId\", \"msgId\", \"mobileNo\", \"mobile\",\n    \"wabaNumber\", \"incomingId\", \"cursorId\", \"dltTemplateId\",\n}\n\ndef loads(body):\n    \"\"\"Parse JSON keeping integers exact and decimals as Decimal.\"\"\"\n    return json.loads(body, parse_float=Decimal)\n\n\ndef id_text(value):\n    \"\"\"Return an identifier as a string, refusing values already damaged.\"\"\"\n    if value is None:\n        return None\n    if isinstance(value, bool):\n        raise TypeError(\"boolean where an identifier was expected\")\n    if isinstance(value, (float, Decimal)):\n        raise TypeError(\"identifier arrived as a non-integer number: %r\" % value)\n    return str(value)\n\n\ndef nested_json(value):\n    \"\"\"Decode a field that carries JSON as a string (once, never twice).\"\"\"\n    if isinstance(value, (dict, list)):\n        return value\n    if not isinstance(value, str) or not value.strip():\n        return None\n    return json.loads(value, parse_float=Decimal)\n\n\ndef status_ok(envelope):\n    \"\"\"Read the status word only; never branch on code or statusCode.\"\"\"\n    return str(envelope.get(\"status\", \"\")).lower() == \"success\"\n\n\ndef epoch_ms(value):\n    \"\"\"Accept epoch milliseconds as a number or a digit string.\"\"\"\n    if value is None or value == \"\":\n        return None\n    text = str(value)\n    if not text.isdigit():\n        raise ValueError(\"not an epoch value: %r\" % value)\n    return int(text)\n\n\ndef normalise_row(row):\n    \"\"\"Copy a report row, turning every known ID field into text.\"\"\"\n    out = dict(row)\n    for key in ID_KEYS &amp; row.keys():\n        out&#91;key] = id_text(row&#91;key])\n    for key in (\"submitTime\", \"deliveryTime\", \"readTime\", \"receivedAt\"):\n        if key in row:\n            out&#91;key] = epoch_ms(row&#91;key])\n    return out\n\n\ndef sms_dlr_rows(envelope):\n    \"\"\"Rows from SMSApi\/reports\/status (method=getDlr).\"\"\"\n    inner = envelope.get(\"response\", {})\n    if not status_ok(inner):\n        return &#91;]\n    return &#91;normalise_row(r) for r in inner.get(\"reports_dlrList\", &#91;])]\n\n\ndef wa_report_rows(envelope):\n    \"\"\"Rows from WAApi\/report.\"\"\"\n    if not status_ok(envelope):\n        return &#91;]\n    data = envelope.get(\"data\") or {}\n    return &#91;normalise_row(r) for r in data.get(\"records\", &#91;])]\n\n\ndef rcs_templates(envelope):\n    \"\"\"Templates from RCSApi\/template\/list, with data decoded.\"\"\"\n    out = &#91;]\n    for t in envelope.get(\"templates\", &#91;]):\n        model = (nested_json(t.get(\"data\")) or {}).get(\"templateModel\", {})\n        out.append({\n            \"name\": t.get(\"name\"),\n            \"type\": t.get(\"type\"),\n            \"active\": type(t.get(\"status\")) is int and t&#91;\"status\"] == 1,\n            \"model\": model,\n        })\n    return out<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the tests, run against the published sample responses with their masked digits filled in. The third test is the one that matters most: after normalisation, the same key has the same Python type on SMS and WhatsApp.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\nfrom decimal import Decimal\nimport msgparse as mp\n\n# Published samples, with masked digits (x) replaced by zeros so they parse.\nSMS_DLR = '''{\"response\": {\"msg\": \"success\",\n \"cursors\": {\"next\": \"9ae9e5e\", \"start\": \"9ae9e5d\"}, \"code\": \"200\",\n \"pages\": {\"current\": 1, \"last\": 12}, \"count\": {\"total\": 23, \"current\": 2},\n \"reports_dlrList\": &#91;{\"country\": \"IN\", \"amount\": 0.125, \"msgType\": \"text\",\n   \"cost\": 1, \"deliveryTime\": 1783887217000, \"length\": 13, \"channel\": \"API\",\n   \"msgId\": \"orderCreate\", \"cause\": \"Template Mismatch\",\n   \"mobileNo\": 917770900000, \"uuId\": \"6358884679059916409\",\n   \"dltTemplateId\": \"120115914280000000\", \"globalErrorCode\": 17,\n   \"cursorId\": 162438749, \"network\": \"Idea\", \"senderName\": \"SMSGAT\",\n   \"flashMsg\": false, \"submitTime\": 1783887217389, \"text\": \"test template\",\n   \"status\": \"FAILED\"}],\n \"action\": \"status\", \"api\": \"reports\", \"status\": \"success\"}}'''\n\nWA_REPORT = '''{\"apiName\": \"report\", \"action\": \"waReport\", \"status\": \"success\",\n \"msg\": \"success\", \"code\": 200, \"data\": {\n \"cursors\": {\"next\": 19382015, \"start\": 18366779},\n \"pages\": {\"current\": 1, \"last\": 1},\n \"records\": &#91;{\"msgType\": \"text\", \"deliveryTime\": 1691165382000,\n   \"billingModel\": \"SIC\", \"msgId\": \"tnNwDCgP1WhfLwn\", \"cause\": \"Read By User\",\n   \"readTime\": 1691165389000, \"mobileNo\": 919000000000,\n   \"uuId\": 3917313917152021246, \"wabaNumber\": 917039600000,\n   \"globalErrorCode\": \"8006\", \"cursorId\": 18366779,\n   \"submitTime\": \"1691165380316\", \"status\": \"DELIVERED\"}],\n \"counts\": {\"total\": 8, \"current\": 6}}}'''\n\nRCS_LIST = json.dumps({\"templates\": &#91;{\n    \"data\": json.dumps({\"templateModel\": {\"name\": \"Flight_Booking\",\n        \"type\": \"carousel\", \"height\": \"SHORT_HEIGHT\", \"width\": \"SMALL_WIDTH\",\n        \"botId\": \"YOUR_BOT_ID\", \"carouselList\": &#91;{\"cardTitle\": \"Your Trip is Confirmed\",\n        \"cardDescription\": \"Mumbai to Dubai\",\n        \"mediaUrl\": \"https:\/\/example.com\/media\/flight.jpg\",\n        \"suggestions\": &#91;{\"suggestionType\": \"reply\", \"displayText\": \"View QR\",\n        \"postback\": \"View QR_&#91;message_id]\", \"preference\": 1}]}],\n        \"createdViaApi\": True}}),\n    \"creationTime\": 1784978480698, \"botName\": \"YourBot\",\n    \"name\": \"Flight_Booking\", \"updationTime\": 1784978480698,\n    \"category\": \"Promotional\", \"type\": \"carousel\", \"status\": 1}]})\n\n\ndef test_sms_dlr_ids_are_text():\n    rows = mp.sms_dlr_rows(mp.loads(SMS_DLR))\n    assert rows&#91;0]&#91;\"uuId\"] == \"6358884679059916409\"\n    assert rows&#91;0]&#91;\"mobileNo\"] == \"917770900000\"\n    assert rows&#91;0]&#91;\"cursorId\"] == \"162438749\"\n    assert rows&#91;0]&#91;\"amount\"] == Decimal(\"0.125\")\n    assert rows&#91;0]&#91;\"submitTime\"] == 1783887217389\n\n\ndef test_wa_report_unquoted_uuid_survives():\n    rows = mp.wa_report_rows(mp.loads(WA_REPORT))\n    assert rows&#91;0]&#91;\"uuId\"] == \"3917313917152021246\"\n    assert rows&#91;0]&#91;\"wabaNumber\"] == \"917039600000\"\n    # submitTime is a string here and a number on SMS; both become int.\n    assert rows&#91;0]&#91;\"submitTime\"] == 1691165380316\n\n\ndef test_same_key_same_shape_across_channels():\n    sms = mp.sms_dlr_rows(mp.loads(SMS_DLR))&#91;0]\n    wa = mp.wa_report_rows(mp.loads(WA_REPORT))&#91;0]\n    for key in (\"uuId\", \"mobileNo\", \"submitTime\", \"deliveryTime\"):\n        assert type(sms&#91;key]) is type(wa&#91;key]), key\n\n\ndef test_rcs_data_decoded_once():\n    t = mp.rcs_templates(mp.loads(RCS_LIST))&#91;0]\n    assert t&#91;\"active\"] is True\n    assert t&#91;\"model\"]&#91;\"carouselList\"]&#91;0]&#91;\"suggestions\"]&#91;0]&#91;\"postback\"] == \"View QR_&#91;message_id]\"\n\n\ndef test_rcs_status_true_is_not_active():\n    env = {\"templates\": &#91;{\"name\": \"x\", \"type\": \"text_message\", \"status\": True, \"data\": \"{}\"}]}\n    assert mp.rcs_templates(env)&#91;0]&#91;\"active\"] is False\n\n\ndef test_damaged_id_is_refused():\n    try:\n        mp.id_text(3.917313917152021e18)\n    except TypeError:\n        return\n    raise AssertionError(\"float id accepted\")\n\n\ndef test_error_envelope_gives_no_rows():\n    env = {\"apiName\": \"report\", \"status\": \"error\", \"code\": 200, \"data\": &#91;]}\n    assert mp.wa_report_rows(env) == &#91;]\n\n\nif __name__ == \"__main__\":\n    for name, fn in sorted(globals().items()):\n        if name.startswith(\"test_\"):\n            fn()\n            print(\"pass\", name)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>id_text<\/code> refusing a float is deliberate. If something upstream has already turned an ID into <code>3.917313917152021e+18<\/code>, converting it back with <code>int()<\/code> gives <code>3917313917152020992<\/code>, which looks like a perfectly good ID. Failing loudly is the only safe answer. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python SMS API tutorial<\/a> covers the send side; this module slots in wherever you read responses.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"nodejs\" class=\"wp-block-heading\">Node.js: Rescue the Source Text in the Reviver<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>JSON.parse<\/code> turns every number into a double before your code ever sees it, so the usual reviver tricks are too late: by the time the reviver gets <code>value<\/code>, the digits are gone. Node 21 and newer (and current Chromium-based browsers) pass a third argument to the reviver, <code>context<\/code>, whose <code>source<\/code> property holds the original text of the number. That&#8217;s enough to recover the exact digits.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The module below checks for that support once at startup. On a runtime that has it, ID keys come back as their source text. On an older runtime, it quotes every integer of 16 or more digits before parsing, using a small scanner that skips anything inside a string, so a message body containing <code>ref: 1234567890123456,<\/code> is left alone.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Lossless JSON parsing for messaging API responses (Node.js 21+).\nconst ID_KEYS = new Set(&#91;\n  'uuId', 'uuid', 'transactionId', 'msgId', 'mobileNo', 'mobile',\n  'wabaNumber', 'incomingId', 'cursorId', 'dltTemplateId',\n]);\n\n\/\/ Does this runtime pass the source text to revivers? (Node 21+, V8 11.4+)\nconst HAS_SOURCE = (() =&gt; {\n  let seen = false;\n  JSON.parse('1', (k, v, ctx) =&gt; { seen = ctx?.source === '1'; return v; });\n  return seen;\n})();\n\nexport function parseLossless(body) {\n  const text = HAS_SOURCE ? body : quoteLongInts(body);\n  return JSON.parse(text, function (key, value, ctx) {\n    if (typeof value !== 'number') return value;\n    const src = ctx?.source ?? String(value);\n    if (ID_KEYS.has(key)) return src;             \/\/ IDs are text, always\n    if (\/^-?\\d+$\/.test(src) &amp;&amp; !Number.isSafeInteger(value)) {\n      return BigInt(src);                         \/\/ a huge counter, if any\n    }\n    return value;\n  });\n}\n\n\/\/ Older runtimes: quote integers of 16+ digits that sit outside strings.\nexport function quoteLongInts(body) {\n  let out = '';\n  let inString = false;\n  for (let i = 0; i &lt; body.length; i++) {\n    const ch = body&#91;i];\n    if (inString) {\n      out += ch;\n      if (ch === '\\\\') { out += body&#91;++i] ?? ''; continue; }\n      if (ch === '\"') inString = false;\n      continue;\n    }\n    if (ch === '\"') { inString = true; out += ch; continue; }\n    const m = \/^-?\\d+(?!&#91;.eE\\d])\/.exec(body.slice(i, i + 40));\n    if (m &amp;&amp; (i === 0 || !\/&#91;\\w.]\/.test(body&#91;i - 1]))) {\n      out += m&#91;0].replace('-', '').length &gt;= 16 ? `\"${m&#91;0]}\"` : m&#91;0];\n      i += m&#91;0].length - 1;\n      continue;\n    }\n    out += ch;\n  }\n  return out;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A short test, which also shows the trap itself on the first line:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import assert from 'node:assert\/strict';\nimport { parseLossless, quoteLongInts } from '.\/parse.mjs';\n\nconst wa = '{\"status\":\"success\",\"code\":200,\"data\":{\"records\":&#91;{\"uuId\":3917313917152021246,\"mobileNo\":919000000000,\"submitTime\":\"1691165380316\",\"deliveryTime\":1691165382000}]}}';\n\nassert.equal(JSON.parse(wa).data.records&#91;0].uuId, 3917313917152021000);\nconst r = parseLossless(wa).data.records&#91;0];\nassert.equal(r.uuId, '3917313917152021246');\nassert.equal(r.mobileNo, '919000000000');\nassert.equal(r.deliveryTime, 1691165382000);\nassert.equal(JSON.parse(quoteLongInts(wa)).data.records&#91;0].uuId, '3917313917152021246');\n\/\/ Strings that merely contain digits are left alone.\nassert.equal(quoteLongInts('{\"text\":\"code 1234567890123456 ok\"}'), '{\"text\":\"code 1234567890123456 ok\"}');\nassert.throws(() =&gt; JSON.stringify({ n: 10n }), TypeError);\nconsole.log('node ok');\nassert.equal(quoteLongInts('{\"text\":\"ref: 1234567890123456, ok\"}'), '{\"text\":\"ref: 1234567890123456, ok\"}');\nassert.equal(quoteLongInts('{\"t\":\"a\\\\\"b: 1234567890123456\",\"n\":12345678901234567}'), '{\"t\":\"a\\\\\"b: 1234567890123456\",\"n\":\"12345678901234567\"}');\nassert.equal(quoteLongInts('&#91;0.1234567890123456789, 1e20, 42]'), '&#91;0.1234567890123456789, 1e20, 42]');\nconsole.log('node scanner ok');<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three things to keep in mind on the Node side:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>BigInt<\/code> doesn&#8217;t serialise.<\/strong> <code>JSON.stringify({ n: 10n })<\/code> throws a <code>TypeError<\/code>. That&#8217;s one reason the module turns IDs into strings rather than <code>BigInt<\/code>: strings go back out through <code>JSON.stringify<\/code> without any special handling.<\/li>\n\n\n\n<li><strong><code>Number(id)<\/code> undoes everything.<\/strong> Once an ID is a string, keep it one. Comparing with <code>===<\/code> against another string is exact; converting either side to a number isn&#8217;t.<\/li>\n\n\n\n<li><strong>Validation libraries can re-coerce.<\/strong> A schema that declares <code>uuId<\/code> as <code>number<\/code> will happily accept the rounded value. Declare ID fields as strings matching <code>^\\d+$<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js SMS API tutorial<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp wire contract<\/a> both show where these responses enter a Node service.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"java\" class=\"wp-block-heading\">Java: Read IDs as Text from the Tree<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Java has the opposite problem from JavaScript. Jackson&#8217;s tree model keeps integers exact: a 19-digit value inside the <code>long<\/code> range becomes a <code>LongNode<\/code>, and anything bigger becomes a <code>BigIntegerNode<\/code>. The trouble comes from what you bind it to. Map <code>uuId<\/code> to a <code>double<\/code> field, or parse with Gson into a <code>Map&lt;String, Object&gt;<\/code>, which gives you <code>Double<\/code> by default, and the digits go.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reading the tree and pulling fields out by hand is the most predictable approach for responses whose types vary by endpoint. <code>idText<\/code> returns text whether the server quoted the ID or not, and <code>bigIntegerValue()<\/code> means it still works if an ID ever outgrows <code>long<\/code>. <code>epochMs<\/code> covers <code>submitTime<\/code> arriving as a number on SMS and a string on WhatsApp.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import com.fasterxml.jackson.databind.DeserializationFeature;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic final class ReportParser {\n    private static final ObjectMapper MAPPER = new ObjectMapper()\n            .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);\n\n    public static final class Row {\n        public final String uuId;\n        public final String mobileNo;\n        public final String status;\n        public final long submitTimeMs;\n\n        Row(String uuId, String mobileNo, String status, long submitTimeMs) {\n            this.uuId = uuId;\n            this.mobileNo = mobileNo;\n            this.status = status;\n            this.submitTimeMs = submitTimeMs;\n        }\n    }\n\n    \/** IDs come back as text whether the server quoted them or not. *\/\n    static String idText(JsonNode n) {\n        if (n == null || n.isNull() || n.isMissingNode()) return null;\n        if (n.isTextual()) return n.textValue();\n        if (n.isIntegralNumber()) return n.bigIntegerValue().toString();\n        throw new IllegalStateException(\"identifier is neither text nor integer: \" + n);\n    }\n\n    \/** submitTime is a number on SMS reports and a string on WhatsApp reports. *\/\n    static long epochMs(JsonNode n) {\n        if (n.isIntegralNumber()) return n.longValue();\n        if (n.isTextual()) return Long.parseLong(n.textValue());\n        throw new IllegalStateException(\"not an epoch value: \" + n);\n    }\n\n    \/** Rows from WAApi\/report. Read status first; bind rows only on success. *\/\n    public static List&lt;Row&gt; waRows(String body) throws IOException {\n        JsonNode root = MAPPER.readTree(body);\n        if (!\"success\".equalsIgnoreCase(root.path(\"status\").asText())) {\n            return Collections.emptyList();\n        }\n        JsonNode records = root.path(\"data\").path(\"records\");\n        List&lt;Row&gt; rows = new ArrayList&lt;&gt;();\n        if (!records.isArray()) return rows;\n        for (JsonNode r : records) {\n            rows.add(new Row(\n                    idText(r.path(\"uuId\")),\n                    idText(r.path(\"mobileNo\")),\n                    r.path(\"status\").asText(),\n                    epochMs(r.path(\"submitTime\"))));\n        }\n        return rows;\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>USE_BIG_DECIMAL_FOR_FLOATS<\/code> makes decimals such as <code>amount<\/code> arrive as <code>DecimalNode<\/code>, so <code>decimalValue()<\/code> gives you an exact <code>BigDecimal<\/code>. If you prefer data binding, give every ID field the type <code>String<\/code>; Jackson&#8217;s default coercion turns a JSON number into its text form for a <code>String<\/code> target. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-java-spring-boot-integration\/\">Java and Spring Boot guide<\/a> covers the two-stage parse (envelope first, typed payload second) that this fits into.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Want to see the trap in plain Java, without any library? <code>(long) Double.parseDouble(\"3917313917152021246\")<\/code> prints <code>3917313917152020992<\/code>. <code>new BigInteger(\"3917313917152021246\")<\/code> prints the original.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"jq-shell\" class=\"wp-block-heading\">jq and Shell Scripts: Quote Before You Parse<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">jq 1.6, which is still what many long-term-support Linux releases install, stores every number as a double. That means <code>jq -r .uuId<\/code> on the WhatsApp report prints <code>3917313917152021000<\/code>, and even <code>jq -c .<\/code> rewrites the number in the output. No filter you write afterwards can get the digits back.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The robust shell answer is to quote long integers before jq sees them. Python is on nearly every box that has jq, and its JSON parser can hand integer text to a callback before converting it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# Quote any JSON integer of 16 or more digits, then hand the result to jq.\n# Usage: curl ... | .\/quote_ids.sh '.data.records&#91;] | &#91;.uuId, .status] | @tsv'\nset -euo pipefail\npython3 -c '\nimport json, sys\ndef keep(s):\n    return s if len(s.lstrip(\"-\")) &gt;= 16 else int(s)\ndoc = json.load(sys.stdin, parse_int=keep, parse_float=str)\njson.dump(doc, sys.stdout)\n' | jq -r \"$1\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use it like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s -X POST 'https:\/\/unify.smsgateway.center\/WAApi\/report' \\\n  --form 'userid=YourUsername' --form 'password=xxxxx' \\\n  --form 'wabaNumber=91XXXXXXXXXX' --form 'fromDate=2026-11-02 00:00:00' \\\n  --form 'toDate=2026-11-02 23:59:59' --form 'pageLimit=20' \\\n  --form 'startCursor=1' --form 'output=json' \\\n  | .\/quote_ids.sh '.data.records&#91;] | &#91;.uuId, .status] | @tsv'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Floats come through as strings too (<code>parse_float=str<\/code>), so <code>0.125<\/code> stays <code>0.125<\/code> rather than picking up binary noise. If you need arithmetic on a field in jq, convert it explicitly with <code>tonumber<\/code> for that one expression and accept the double there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Newer jq releases (1.7 onwards) keep the original text of a number literal as long as you don&#8217;t do arithmetic on it. Check whatever is installed rather than trusting the version string:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>echo '{\"uuId\": 3917313917152021246}' | jq -r .uuId\n# Safe if it prints 3917313917152021246. Pre-quote if it prints 3917313917152021000.<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"json-in-json\" class=\"wp-block-heading\">JSON Inside JSON: Fields That Hold Strings<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some fields carry a whole JSON document as a string value. On the RCS template list, each template&#8217;s <code>data<\/code> field looks like this (shortened):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"data\": \"{\\\"templateModel\\\":{\\\"name\\\":\\\"Flight_Booking\\\",\\\"type\\\":\\\"carousel\\\",\\\"height\\\":\\\"SHORT_HEIGHT\\\",\\\"width\\\":\\\"SMALL_WIDTH\\\",\\\"carouselList\\\":&#91;{\\\"cardTitle\\\":\\\"Your Trip is Confirmed\\\",\\\"suggestions\\\":&#91;{\\\"suggestionType\\\":\\\"reply\\\",\\\"displayText\\\":\\\"View QR\\\",\\\"postback\\\":\\\"View QR_&#91;message_id]\\\",\\\"preference\\\":1}]}],\\\"createdViaApi\\\":true}}\",\n  \"name\": \"Flight_Booking\",\n  \"type\": \"carousel\",\n  \"status\": 1\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The outer parse gives you a string. You have to parse that string a second time to reach <code>templateModel<\/code>. On the way out, <code>RCSApi\/send<\/code> expects the same shape in reverse: <code>customParams<\/code> inside <code>msg<\/code> is itself a JSON string, so you serialise the parameter object first and then embed the resulting string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three habits keep this from biting:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Decode exactly once, in the boundary.<\/strong> The <code>nested_json<\/code> helper in <a href=\"#python\">the Python module<\/a> accepts a string and decodes it, accepts an object or array and returns it unchanged, and returns <code>None<\/code> for an empty value. If a nested field ever arrives already decoded, nothing breaks.<\/li>\n\n\n\n<li><strong>Apply the same number rules inside.<\/strong> The inner document is JSON too. Parse it with the same lossless settings as the outer one, or a long number inside it rounds where you aren&#8217;t looking.<\/li>\n\n\n\n<li><strong>Build outbound strings with a serialiser, never by concatenation.<\/strong> A customer name containing a quote mark breaks hand-built JSON. Serialise the inner object with your JSON library, then put the resulting string into the outer object and serialise that.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the outbound side for an RCS template send in Python. The inner <code>customParams<\/code> object is serialised on its own, then embedded:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\n\n# Key names follow your template's variables; the point here is the double serialisation.\ncustom_params = {\"name\": \"Asha\", \"orderid\": \"ORD-20391\"}\nmsg = {\n    \"contentMessage\": {\n        \"templateMessage\": {\n            \"templateCode\": \"Flight_Booking\",\n            \"customParams\": json.dumps(custom_params, ensure_ascii=True),\n        }\n    },\n    \"ttl\": \"86400s\",\n}\nform_value = json.dumps(msg, ensure_ascii=True)  # goes in the msg form field\nassert json.loads(json.loads(form_value)&#91;\"contentMessage\"]&#91;\"templateMessage\"]&#91;\"customParams\"]) == custom_params<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-rich-cards-carousels-content-model-validation\/\">RCS rich cards and carousels guide<\/a> goes deeper into the <code>templateModel<\/code> that comes out of <code>data<\/code> and how to diff it against what you submitted.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"envelope\" class=\"wp-block-heading\">Reading the Envelope Without Trusting Its Types<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The envelope tells you whether the call worked. Its fields aren&#8217;t typed consistently either:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Endpoint<\/th><th>Status field<\/th><th>Code field<\/th><th>Code type<\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/send<\/code><\/td><td><code>\"status\": \"success\"<\/code><\/td><td><code>statusCode<\/code><\/td><td>string <code>\"200\"<\/code><\/td><\/tr><tr><td><code>SMSApi\/reports\/status<\/code> (<code>getDlr<\/code>)<\/td><td><code>response.status<\/code><\/td><td><code>response.code<\/code><\/td><td>string <code>\"200\"<\/code><\/td><\/tr><tr><td><code>WAApi\/report<\/code><\/td><td><code>status<\/code><\/td><td><code>code<\/code><\/td><td>number <code>200<\/code><\/td><\/tr><tr><td><code>rest\/rcs\/v1\/inbox<\/code><\/td><td><code>status<\/code><\/td><td><code>statusCode<\/code><\/td><td>string <code>\"200\"<\/code> (and <code>\"403\"<\/code> on the error sample)<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The status word is the one consistent signal, so read that. Notice the nesting, too: on <code>getDlr<\/code> it lives under <code>response<\/code>, while on <code>WAApi\/report<\/code> it sits at the top level. A check written as <code>body.code === 200<\/code> passes on WhatsApp and fails on SMS. A check written as <code>body.statusCode == \"200\"<\/code> fails on both reports. A check on <code>status<\/code> at the right level works everywhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The shape that holds up across all of these:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Parse loosely, into a generic tree or dictionary.<\/li>\n\n\n\n<li>Read <code>status<\/code> at the level that endpoint uses.<\/li>\n\n\n\n<li>If it isn&#8217;t <code>success<\/code>, stop. Don&#8217;t bind the payload, because error replies can carry a different shape (an empty array where an object would be, for example).<\/li>\n\n\n\n<li>Only then bind the typed payload.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Keep <code>code<\/code>, <code>statusCode<\/code>, <code>msg<\/code> and <code>reason<\/code> for logging. Don&#8217;t branch on them, and don&#8217;t parse the human-readable text in <code>msg<\/code> or <code>reason<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"money\" class=\"wp-block-heading\">Money and Rates: Decimal, Never Float<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The SMS delivery report carries <code>\"amount\": 0.125<\/code> per row. That particular value happens to be exact in binary, because it&#8217;s one eighth. Most rates aren&#8217;t. Add up a published rate of 0.17 ten thousand times with floats and you get <code>1700.0000000001817<\/code>. Do the same with <code>Decimal<\/code> and you get <code>1700.00<\/code>. Over a month of reconciliation that drift is real money, and it stops your totals matching the invoice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rules are short:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Parse decimals as decimals.<\/strong> <code>json.loads(body, parse_float=Decimal)<\/code> in Python, <code>USE_BIG_DECIMAL_FOR_FLOATS<\/code> in Jackson. In JavaScript, use the same source-text reviver approach to keep <code>amount<\/code> as a string, then hand it to a decimal library.<\/li>\n\n\n\n<li><strong>Store in <code>NUMERIC<\/code>, never <code>REAL<\/code> or <code>FLOAT<\/code>.<\/strong> Four decimal places covers the rates on these surfaces.<\/li>\n\n\n\n<li><strong>Sum in the database or in decimal types.<\/strong> Convert to float only for display, at the very end.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">Reconciling a messaging invoice across four channels<\/a> builds the ledger these amounts feed into.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"timestamps\" class=\"wp-block-heading\">Timestamps: Numbers, Strings and Formatted Twins<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Time fields come in three flavours on these surfaces:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Field<\/th><th>Where<\/th><th>JSON type<\/th><th>Unit<\/th><\/tr><\/thead><tbody><tr><td><code>submitTime<\/code><\/td><td><code>getDlr<\/code> rows<\/td><td>number<\/td><td>epoch milliseconds<\/td><\/tr><tr><td><code>submitTime<\/code><\/td><td><code>WAApi\/report<\/code> rows<\/td><td>string<\/td><td>epoch milliseconds<\/td><\/tr><tr><td><code>deliveryTime<\/code>, <code>readTime<\/code><\/td><td><code>WAApi\/report<\/code> rows<\/td><td>number<\/td><td>epoch milliseconds<\/td><\/tr><tr><td><code>receivedAt<\/code><\/td><td>RCS inbox rows<\/td><td>number<\/td><td>epoch milliseconds<\/td><\/tr><tr><td><code>receivedAtFormatted<\/code><\/td><td>RCS inbox rows<\/td><td>string<\/td><td><code>YYYY-MM-DD HH:MM:SS<\/code>, no zone<\/td><\/tr><tr><td><code>creationTime<\/code>, <code>updationTime<\/code><\/td><td>RCS template list<\/td><td>number<\/td><td>epoch milliseconds<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Epoch milliseconds right now are 13 digits, comfortably inside the safe-integer range, so doubles don&#8217;t hurt them. The risk here is type, not precision: <code>\"1691165380316\" &gt; 1783887217389<\/code> compares a string to a number, and every language has its own opinion about what that means.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Normalise every epoch field to a 64-bit integer at the boundary, accepting either a number or a string of digits. The <code>epoch_ms<\/code> helper in Python and <code>epochMs<\/code> in Java both do that. Then store as <code>BIGINT<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When a row carries both an epoch value and a formatted string, as the RCS inbox does with <code>receivedAt<\/code> and <code>receivedAtFormatted<\/code>, use the epoch value. It&#8217;s unambiguous. The formatted string has no zone, so you&#8217;d have to guess one, and two sources that disagree are worse than one.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"phones-pins\" class=\"wp-block-heading\">Phone Numbers and PINs Are Text<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>mobileNo<\/code> arrives as a bare number on both the SMS and WhatsApp report samples, and as a string on the RCS inbox. Twelve-digit Indian mobile numbers with the <code>91<\/code> prefix fit easily inside a double, so they won&#8217;t round. They&#8217;re still not numbers:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>You never add or compare them by size.<\/li>\n\n\n\n<li>Other countries&#8217; numbers can be longer, and formats with a leading <code>+<\/code> or <code>0<\/code> can&#8217;t be numbers at all.<\/li>\n\n\n\n<li>A number column in a spreadsheet shows them in scientific notation.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Turn them into strings at the boundary, same as IDs. The <code>ID_KEYS<\/code> set in the Python module includes <code>mobileNo<\/code>, <code>mobile<\/code> and <code>wabaNumber<\/code> for exactly this reason.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">PINs are the sharper case. The voice OpenAPI file types the OTP <code>pin<\/code> as an integer. An OTP of <code>042913<\/code> sent as the integer <code>42913<\/code> is a five-digit number, and the call reads out the wrong code. Generate OTPs as digit strings and send them as strings. Whether the server reads <code>pin<\/code> as text or as a number, a string with its leading zero intact is correct either way. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">Voice Call API wire contract<\/a> covers the rest of that endpoint.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"writing-back\" class=\"wp-block-heading\">Writing It Back: Storage, Logs and Outbound JSON<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Getting the parse right is half the job. The other half is not undoing it on the way out.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-lossless-parse-boundary-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-lossless-parse-boundary-flow.svg\" alt=\"Three-panel flow from raw response to boundary normaliser to typed record, with banners on double precision, testing against published samples and refusing float IDs\" class=\"wp-image-3135\"\/><\/a><figcaption class=\"wp-element-caption\">One boundary does every conversion. Nothing downstream ever sees the raw types.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Storage.<\/strong> Use <code>TEXT<\/code> for every identifier and phone number, <code>BIGINT<\/code> for epoch milliseconds, and <code>NUMERIC<\/code> for money. Here&#8217;s a delivery report table that follows those rules (the DDL runs unchanged on PostgreSQL and SQLite):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE delivery_report (\n  channel        TEXT    NOT NULL,          -- 'sms' | 'whatsapp' | 'rcs' | 'voice'\n  provider_id    TEXT    NOT NULL,          -- uuId \/ transactionId, as text\n  msisdn         TEXT    NOT NULL,          -- mobileNo, as text\n  status         TEXT    NOT NULL,          -- DELIVERED, FAILED, ... (open set)\n  submit_ms      BIGINT,                    -- epoch milliseconds\n  delivery_ms    BIGINT,\n  amount         NUMERIC(12, 4),            -- never a float column\n  raw_body_sha   TEXT,                      -- hash of the response it came from\n  PRIMARY KEY (channel, provider_id, msisdn, submit_ms)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>REAL<\/code> column silently does the same damage as <code>JSON.parse<\/code>. Insert <code>3917313917152021246<\/code> into a SQLite column declared <code>REAL<\/code> and read it back as an integer: you get <code>3917313917152020992<\/code>. A <code>TEXT<\/code> column hands back the exact string, and an equality lookup on it matches. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table<\/a> uses the same column types for the send side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Logs.<\/strong> Log IDs as quoted strings. Structured loggers that serialise to JSON will otherwise emit them as numbers, and your log search tool will parse them as doubles. Then a search for the real ID finds nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Outbound JSON.<\/strong> Any API you expose to your own frontends or partners should send IDs as strings, even if your internal type is an integer. That&#8217;s the only representation every client parses the same way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>CSV exports.<\/strong> Quote ID columns, and tell people who open them in a spreadsheet to import those columns as text.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"contract-tests\" class=\"wp-block-heading\">A Parsing Contract You Can Test<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Types drift. A field that&#8217;s quoted today could arrive bare after a platform release, or the other way round. You want to find out from a failing test, not from a reconciliation report three weeks later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pin the contract with three kinds of test:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Golden fixtures from the published samples.<\/strong> Copy each sample response into a fixture file, fill in the masked digits, and run it through your boundary. The Python tests above do exactly that for <code>getDlr<\/code>, <code>WAApi\/report<\/code> and the RCS template list.<\/li>\n\n\n\n<li><strong>A same-key, same-type assertion.<\/strong> After normalisation, <code>uuId<\/code>, <code>mobileNo<\/code>, <code>submitTime<\/code> and <code>deliveryTime<\/code> must have the same type on every channel. This catches the day someone adds a new endpoint and forgets to route it through the boundary.<\/li>\n\n\n\n<li><strong>A live shape probe.<\/strong> Once a night, call a read-only endpoint against your own account, run the raw body through a check that records the JSON type of each ID field, and alert if any type changes. Store the result, not the body. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">contract testing harness<\/a> shows how to run probes like this without sending a single message.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">One more test worth having: feed your boundary an ID as a float and make sure it raises. That&#8217;s <code>test_damaged_id_is_refused<\/code> in the Python suite. It guards against someone &#8220;simplifying&#8221; the helper later.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix: Which Fix for Which Stack<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Your situation<\/th><th>Do this<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Python service, writes to a database<\/td><td><code>json.loads(..., parse_float=Decimal)<\/code>, IDs through <code>id_text<\/code>, <code>TEXT<\/code> columns<\/td><td>Integers already exact; protect money and the next hop<\/td><\/tr><tr><td>Python service that returns JSON to a browser<\/td><td>Same, and emit IDs as strings<\/td><td>The browser&#8217;s <code>JSON.parse<\/code> is where the rounding would happen<\/td><\/tr><tr><td>Node.js 21 or newer<\/td><td>Reviver reading <code>context.source<\/code> for ID keys<\/td><td>Exact digits with no pre-processing<\/td><\/tr><tr><td>Node.js 20 or older, or a runtime you don&#8217;t control<\/td><td>Scanner that quotes 16+ digit integers outside strings, then <code>JSON.parse<\/code><\/td><td>Source text isn&#8217;t available, so fix the text first<\/td><\/tr><tr><td>Java with Jackson<\/td><td><code>readTree<\/code>, <code>idText<\/code> helper, <code>USE_BIG_DECIMAL_FOR_FLOATS<\/code><\/td><td>Tree keeps integers exact; binding decides what survives<\/td><\/tr><tr><td>Java with Gson<\/td><td>Typed classes with <code>String<\/code> ID fields, or a <code>ToNumberPolicy<\/code><\/td><td><code>Object<\/code> and <code>Map<\/code> targets default to <code>Double<\/code><\/td><\/tr><tr><td>Go<\/td><td><code>Decoder.UseNumber()<\/code> and <code>json.Number<\/code>, or typed structs with <code>string<\/code> IDs<\/td><td><code>interface{}<\/code> targets default to <code>float64<\/code><\/td><\/tr><tr><td>PHP<\/td><td><code>json_decode($body, true, 512, JSON_BIGINT_AS_STRING)<\/code><\/td><td>Keeps oversized integers as strings<\/td><\/tr><tr><td>Shell with jq 1.6<\/td><td>Pre-quote with <code>quote_ids.sh<\/code><\/td><td>jq 1.6 rounds every number<\/td><\/tr><tr><td>Shell with newer jq<\/td><td>Run the one-line check; pre-quote anyway if it prints the rounded value<\/td><td>Behaviour depends on the build, so measure it<\/td><\/tr><tr><td>Spreadsheet or BI tool<\/td><td>Import ID and phone columns as text<\/td><td>Spreadsheets keep 15 significant digits<\/td><\/tr><\/tbody><\/table><\/div><\/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\">Implementation Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>List every endpoint you read, and every field in its response that holds an ID, a phone number, a time or money.<\/li>\n\n\n\n<li>Route every response through one boundary module. No endpoint-specific parsing outside it.<\/li>\n\n\n\n<li>Turn ID and phone fields into text by key name, whichever JSON type they arrive as.<\/li>\n\n\n\n<li>Refuse an ID that arrives as a float or non-integer number.<\/li>\n\n\n\n<li>Parse decimals as decimals and store money in <code>NUMERIC<\/code>.<\/li>\n\n\n\n<li>Accept epoch milliseconds as a number or a digit string; store as <code>BIGINT<\/code>.<\/li>\n\n\n\n<li>Prefer epoch fields over formatted twins with no zone.<\/li>\n\n\n\n<li>Decode JSON-in-a-string fields once, and accept an already-decoded object too.<\/li>\n\n\n\n<li>Build outbound nested JSON with a serialiser, never by string concatenation.<\/li>\n\n\n\n<li>Read <code>status<\/code> at the right nesting level; never branch on <code>code<\/code> or <code>statusCode<\/code>.<\/li>\n\n\n\n<li>Store IDs and phone numbers in <code>TEXT<\/code> columns; check no <code>REAL<\/code> or <code>FLOAT<\/code> column holds one.<\/li>\n\n\n\n<li>Log IDs as quoted strings, and send them as strings in any JSON you emit.<\/li>\n\n\n\n<li>Generate OTPs and PINs as digit strings and send them as strings.<\/li>\n\n\n\n<li>Add golden fixtures from the published samples with masked digits filled in.<\/li>\n\n\n\n<li>Assert that the same key has the same type on every channel after normalisation.<\/li>\n\n\n\n<li>Check your jq build with the one-line test, or pre-quote.<\/li>\n\n\n\n<li>Run a nightly probe that records the JSON type of each ID field and alerts on change.<\/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\">A few behaviours that affect parsing aren&#8217;t pinned down by anything you can read today. Each item below gives you the choice that stays correct either way, and none of them needs you to wait for an answer before shipping.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Treat every ID as text, including the ones that are quoted today.<\/strong> <code>uuId<\/code> is quoted on SMS reports and bare on WhatsApp reports. Nothing promises either will stay that way. A boundary that returns text from a string or an integer is correct in both cases.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Don&#8217;t assume IDs fit in a signed 64-bit integer.<\/strong> Every sample ID sits below 9,223,372,036,854,775,807, but no maximum is published. Text has no ceiling, and in Java <code>bigIntegerValue()<\/code> works whether or not the value fits in <code>long<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Read <code>status<\/code>, and treat <code>code<\/code> and <code>statusCode<\/code> as log-only.<\/strong> Their types already differ between endpoints. If they change again, code that never branched on them doesn&#8217;t notice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Accept every epoch field as a number or a digit string.<\/strong> <code>submitTime<\/code> is already both, depending on the channel. Applying the same rule to <code>deliveryTime<\/code>, <code>readTime<\/code>, <code>receivedAt<\/code> and <code>creationTime<\/code> costs one helper and survives any of them switching.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Decode nested JSON fields if they&#8217;re strings and pass them through if they&#8217;re objects.<\/strong> <code>data<\/code> on the RCS template list is a string today. A helper that handles both shapes keeps working if that ever changes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Pass paging cursors back exactly as you received them.<\/strong> <code>getDlr<\/code> cursors look like short hex strings and <code>WAApi\/report<\/code> cursors are numbers. Store the cursor as text and send it back as text. You never need to know what it encodes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Treat <code>cursorId<\/code> and <code>incomingId<\/code> as IDs, even though they&#8217;re short.<\/strong> They&#8217;re small enough to survive a double today. Keeping them in the text set means you don&#8217;t have to revisit the decision if they grow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Keep fields you don&#8217;t recognise.<\/strong> A new field in a report row shouldn&#8217;t fail the parse. Keep the whole row in a JSON column, or at least a hash of the raw body, and bind only the fields you know.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Read <code>amount<\/code> from its source text, never from a float.<\/strong> Whether the server sends <code>0.125<\/code> or <code>\"0.125\"<\/code>, <code>Decimal(str(value))<\/code> from the source text is exact. Converting a float to Decimal copies the float&#8217;s binary error.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Treat delivery statuses as an open set.<\/strong> <code>DELIVERED<\/code>, <code>FAILED<\/code> and <code>PENDING<\/code> appear in the SMS samples, <code>READ<\/code> is mentioned on the WhatsApp page. Store the string, map the ones you know, and route anything else to an &#8220;unknown&#8221; bucket that you alert on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Fill masked sample values with digits of the same length.<\/strong> A fixture built from <code>9177709xxxxx<\/code> with zeros in place of the <code>x<\/code> characters keeps the 12-digit shape your normaliser has to handle, and parses.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Compare IDs as strings, never as numbers.<\/strong> String equality on normalised text is exact everywhere. Numeric equality is exact only in some languages, and only until someone adds a conversion.<\/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<h3 class=\"wp-block-heading\">Why does JSON.parse change my 19-digit message ID?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Because it converts every JSON number to a double, and a double holds integers exactly only up to 9,007,199,254,740,991. A 19-digit ID like <code>3917313917152021246<\/code> becomes <code>3917313917152021000<\/code> when printed and <code>3917313917152020992<\/code> in memory. No error is raised. Read the source text in a reviver (Node 21 and newer) or quote long integers before parsing.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is Python safe for large message IDs?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, for integers. Python&#8217;s <code>json<\/code> module parses them into exact <code>int<\/code> values. It isn&#8217;t safe for decimals unless you pass <code>parse_float=Decimal<\/code>, and it can&#8217;t protect the next system: if you send the ID on to a browser as a JSON number, it rounds there. Convert IDs to strings at the boundary.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I store message IDs in a BIGINT column?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>TEXT<\/code>. A <code>BIGINT<\/code> holds today&#8217;s sample IDs exactly, but there&#8217;s no published maximum, and every hop through a language without 64-bit integers is another chance to round. Text has no ceiling, compares exactly, and survives every client.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does BigInt fix the problem in JavaScript?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Only if you create it from the source text, before the number becomes a double. <code>BigInt(JSON.parse(body).uuId)<\/code> is too late, because the digits are already gone. <code>BigInt<\/code> also throws in <code>JSON.stringify<\/code>, so for IDs a plain string is the simpler choice.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why is uuId a string on the SMS report and a number on the WhatsApp report?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The two endpoints serialise the same concept differently: <code>SMSApi\/reports\/status<\/code> quotes <code>uuId<\/code>, <code>WAApi\/report<\/code> doesn&#8217;t. Your code shouldn&#8217;t care. Normalise by field name at one boundary, and both become text.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I read the RCS template list&#8217;s data field?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Parse it a second time. The outer parse gives you a string. Decoding that string gives you <code>{\"templateModel\": {...}}<\/code>. Use the same lossless settings for the inner parse, and write the helper so it also accepts an object, in case the field ever arrives decoded.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Are phone numbers safe to keep as numbers?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Twelve-digit numbers fit in a double, so they won&#8217;t round, but they aren&#8217;t quantities. Spreadsheets show them in scientific notation, other formats carry a <code>+<\/code> or a leading <code>0<\/code>, and an OTP with a leading zero loses a digit as an integer. Keep phone numbers and PINs as text.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can jq handle 19-digit IDs?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">jq 1.6 can&#8217;t; it rounds every number. Newer builds keep number literals intact if you don&#8217;t do arithmetic on them. Run <code>echo '{\"uuId\": 3917313917152021246}' | jq -r .uuId<\/code> and check the output. If it&#8217;s rounded, pre-quote long integers with a small Python filter before jq.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Keep Every Digit From the First Response to the Last Report<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Building a delivery report pipeline, an invoice reconciler or an inbox across SMS, WhatsApp, RCS and voice? Try every endpoint in this guide against the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">SMSGatewayCenter sandbox<\/a>, run the sample responses through the boundary module, and get in touch through <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">our contact page<\/a> when you&#8217;re ready to move to production volumes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More from our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-rich-cards-carousels-content-model-validation\/\">RCS Rich Cards and Carousels as a Content Model: Validate, Version and Send Cards Without Surprises<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/\">Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-data-retention-erasure-sms-whatsapp-rcs-telegram\/\">Data Retention and Erasure for SMS, WhatsApp, RCS and Telegram: Deleting a Person Without Breaking Opt-Outs<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-account-security-audit-trail-activity-logs-connected-devices\/\">SMS Account Security Audit Trail: Activity Logs, Login Alerts and Connected Devices via API<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A messaging API can hand you the same transaction ID as a quoted string on one endpoint and a bare 19-digit number on the next. Default JSON parsers in JavaScript, jq and several Java and Go setups round that number silently. Here&#8217;s how to read SMS, WhatsApp, RCS and voice responses in Python, Node.js, Java and shell without losing a digit.<\/p>\n","protected":false},"author":118,"featured_media":3134,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2383,2387,2016,2384,2386,2380,2382,2385,2232,481,2381,632],"class_list":["post-3133","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-bigint","tag-data-integrity","tag-delivery-reports","tag-java","tag-jq","tag-json-parsing","tag-node-js","tag-python","tag-rcs-api","tag-sms-api","tag-voice-api","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3133","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=3133"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3133\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3134"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3133"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3133"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3133"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}