{"id":3050,"date":"2026-09-28T14:54:08","date_gmt":"2026-09-28T09:24:08","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3050"},"modified":"2026-09-28T14:54:11","modified_gmt":"2026-09-28T09:24:11","slug":"contract-testing-harness-messaging-api","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/","title":{"rendered":"Contract Testing a Messaging API: A Nightly Drift Harness That Never Sends a Message"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A provider you do not control will never run your Pact file. This guide builds a nightly harness that probes only free, read-only <strong>SMSGatewayCenter<\/strong> endpoints, fingerprints every response by path, JSON type and lexical class, provokes the error branch on purpose, diffs the two code catalogues row by row, and pins known quirks so that a quirk disappearing is caught as drift too. Python, Node.js, Java and PHP samples, each showing a different trap.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contract-testing-harness-messaging-api-featured.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contract-testing-harness-messaging-api-featured-1024x584.webp\" alt=\"Two nearly identical geometric lattices in teal and blue with three differing cells outlined in orange, representing a schema diff.\" class=\"wp-image-3051\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contract-testing-harness-messaging-api-featured-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contract-testing-harness-messaging-api-featured-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contract-testing-harness-messaging-api-featured-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/contract-testing-harness-messaging-api-featured.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">A drift harness compares the shape of today&#8217;s response with yesterday&#8217;s and ignores the values.<\/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-not-pact\">Why Consumer-Driven Contracts Stall on a Third-Party Provider<\/a><\/li>\n\n\n\n<li><a href=\"#read-only-surface\">The Free Read-Only Surface You Can Probe Every Night<\/a><\/li>\n\n\n\n<li><a href=\"#shape-fingerprint\">Fingerprint the Shape, Ignore the Values<\/a><\/li>\n\n\n\n<li><a href=\"#lexical-classes\">Lexical Classes: The Part Generic Drift Tools Miss<\/a><\/li>\n\n\n\n<li><a href=\"#probe-error-branch\">Probe the Error Branch on Purpose<\/a><\/li>\n\n\n\n<li><a href=\"#code-catalogues\">The Two Code Catalogues Are a Data Contract<\/a><\/li>\n\n\n\n<li><a href=\"#classifying-diffs\">Classifying a Diff: Additive, Narrowing, Type Flip, Semantic<\/a><\/li>\n\n\n\n<li><a href=\"#quirk-ledger\">The Quirk Ledger: Pin Known Deviations as Expected<\/a><\/li>\n\n\n\n<li><a href=\"#harness-architecture\">Harness Architecture<\/a><\/li>\n\n\n\n<li><a href=\"#python-fingerprinter\">Python: The Fingerprinter and the Differ<\/a><\/li>\n\n\n\n<li><a href=\"#nodejs-big-numbers\">Node.js: Catch Nineteen-Digit Numbers Before JSON.parse<\/a><\/li>\n\n\n\n<li><a href=\"#java-catalogue-diff\">Java: A Catalogue Diff That Fails Closed<\/a><\/li>\n\n\n\n<li><a href=\"#php-allowlist\">PHP: An Allowlist That Makes Writes Impossible<\/a><\/li>\n\n\n\n<li><a href=\"#snapshots-alerting\">Storing Snapshots and Alerting Without Noise<\/a><\/li>\n\n\n\n<li><a href=\"#endpoint-lessons\">What Each Endpoint Teaches the Harness<\/a><\/li>\n\n\n\n<li><a href=\"#ci-release-gate\">Wiring It Into CI and the Release Gate<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix: Which Check Catches Which Drift<\/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\">You cannot make a messaging provider run your consumer contracts, so you verify its contract from your side, every night, using only endpoints that cost nothing and change nothing. On SMSGatewayCenter that surface is large: the two code catalogues (<code>SMSApi\/info\/responsecodes<\/code> and <code>SMSApi\/info\/deliverycodes<\/code>), the rate plan, the profile and account status reads, the sender ID and template reads, the RCS bot list and analytics, the WhatsApp analytics, and the Telegram template list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For each of them, the harness does four things. It calls the success branch and records a <strong>fingerprint<\/strong>: every JSON path with its type and a lexical class such as &#8220;string of digits&#8221; or &#8220;four-decimal string&#8221;, never the value. It provokes the <strong>error branch<\/strong> on purpose with a harmless bad parameter, because on this platform the error branch often changes the type of the payload key (an object or list becomes <code>[]<\/code>, or the key is dropped). It diffs the two <strong>code catalogues row by row<\/strong>, because a code flipping from <code>success<\/code> to <code>error<\/code> is a semantic change no shape check will see. And it checks every difference against a <strong>quirk ledger<\/strong>, a file of known deviations you already code around, so that a quirk which quietly disappears is flagged as drift too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The whole run costs a dozen read calls. It catches the failures that hurt most in messaging: a parser that silently loses precision on a nineteen-digit identifier, a status read at the wrong nesting depth, and a new failure code your retry logic treats as transient.<\/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>Consumer-driven contracts need the provider to run your pact.<\/strong> A third-party messaging API will not, so you verify its behaviour yourself, on a schedule, against the live read-only surface.<\/li>\n\n\n\n<li><strong>Never let the harness reach a billable or mutating endpoint.<\/strong> Allowlist exact path and method pairs. Several endpoints select the operation by HTTP method, so a path allowlist alone is not enough.<\/li>\n\n\n\n<li><strong>Fingerprint shapes, not values.<\/strong> Record path, JSON type and lexical class. A <code>count<\/code> moving from 198 to 199 is noise. <code>\"200\"<\/code> becoming <code>200<\/code> is drift.<\/li>\n\n\n\n<li><strong>Lexical classes matter more than types.<\/strong> <code>\"2707\"<\/code> and <code>\"Full Name\"<\/code> are both strings; one is an identifier. <code>\"0.1700\"<\/code> is a string holding a fixed four-decimal amount. <code>\"Jul 08\"<\/code> is a date label with no year.<\/li>\n\n\n\n<li><strong>Parse the raw text so long integers survive.<\/strong> Some rows carry unquoted nineteen-digit identifiers. JavaScript&#8217;s <code>JSON.parse<\/code> rounds them without an error. Flag them before parsing.<\/li>\n\n\n\n<li><strong>Probe the error branch deliberately.<\/strong> RCS analytics turns <code>analytics<\/code> into <code>[]<\/code> on failure, the RCS bot list turns <code>botsList<\/code> into <code>[]<\/code> with 403, and the rate plan omits <code>data<\/code>. Fingerprint success and error as two contracts.<\/li>\n\n\n\n<li><strong>Diff the code catalogues as data.<\/strong> Both lists wrap each row in a key named <code>errorcode<\/code>. On the response list the inner key is <code>errorCode<\/code> in camel case. Key the delivery list on the pair <code>peId<\/code> plus <code>identifier<\/code>.<\/li>\n\n\n\n<li><strong>Pin known quirks.<\/strong> A ledger entry says &#8220;this path is expected to be an unquoted integer&#8221;. If it becomes quoted, that is also drift, because your coercion may now double-handle it.<\/li>\n\n\n\n<li><strong>Route by severity.<\/strong> Additive changes log, narrowing changes fail the release gate, type flips page someone, catalogue changes open a ticket to classify the new code.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-not-pact\" class=\"wp-block-heading\">Why Consumer-Driven Contracts Stall on a Third-Party Provider<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Consumer-driven contract testing, as popularised by <a href=\"https:\/\/docs.pact.io\/\" target=\"_blank\" rel=\"noopener nofollow\">Pact<\/a>, works in two halves. The consumer records the interactions it depends on into a contract file. The provider replays that file against its own build and refuses to ship if any interaction breaks. The second half is the one that protects you, and it only happens if the provider runs it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A messaging platform serves thousands of integrations. It will not replay your pact before each release, and you would not want it to gate its releases on your file anyway. So the half of the pattern that catches provider drift is missing. What remains is still useful for your own code, and it is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/\">four-layer testing guide for code that sends messages<\/a>. That guide treats contract checks as one layer of your test pyramid: fixtures captured from real responses, plus a nightly catalogue diff.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide builds the missing half as a standalone system: a provider-verification harness that you own and run. The difference is not cosmetic.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Question<\/th><th>Consumer-driven contract (Pact style)<\/th><th>Provider verification harness (this guide)<\/th><\/tr><\/thead><tbody><tr><td>Who runs the check<\/td><td>The provider, in its pipeline<\/td><td>You, on a schedule, against production<\/td><\/tr><tr><td>What it compares<\/td><td>Recorded interactions against the provider build<\/td><td>Today&#8217;s live response shapes against yesterday&#8217;s<\/td><\/tr><tr><td>What it catches<\/td><td>A provider change that breaks a known consumer<\/td><td>Any shape change, including on paths you do not read yet<\/td><\/tr><tr><td>Cost per run<\/td><td>Zero for you<\/td><td>A dozen free read calls<\/td><\/tr><tr><td>When you find out<\/td><td>Before the provider ships<\/td><td>Within one schedule interval after it ships<\/td><\/tr><tr><td>What it needs from the provider<\/td><td>Participation<\/td><td>Nothing beyond the public API<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two properties follow from that table. First, you find out after the change ships, so the harness must run often enough that &#8220;after&#8221; means hours, not weeks. Nightly is the practical floor; hourly is cheap if you keep the probe list short. Second, because you are not limited to the paths your code reads, you get early warning on fields you are about to start using. That matters on a platform where the same entity is spelled and typed differently on the way out and the way back, as the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sender-identity-sms-whatsapp-rcs-telegram\/\">sender identity comparison across four channels<\/a> shows in detail.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"read-only-surface\" class=\"wp-block-heading\">The Free Read-Only Surface You Can Probe Every Night<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A drift harness is only safe if every call it makes is free and has no side effect. The table below lists read-only endpoints on the platform with the facts a harness needs: the base, the method, the list or payload key, and whether the published sample shows an error shape. All calls go to <code>https:\/\/unify.smsgateway.center\/<\/code> and authenticate with <code>userid<\/code> plus the <code>apikey<\/code> header.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Method<\/th><th>Payload key<\/th><th>Error branch in published sample<\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/info\/responsecodes<\/code><\/td><td>POST only<\/td><td><code>response.responsecodesList<\/code><\/td><td>Not shown<\/td><\/tr><tr><td><code>SMSApi\/info\/deliverycodes<\/code><\/td><td>POST only<\/td><td><code>response.deliverycodesList<\/code><\/td><td>Not shown<\/td><\/tr><tr><td><code>SMSApi\/account\/readprofile<\/code><\/td><td>POST or GET<\/td><td><code>response.account<\/code><\/td><td>Not shown<\/td><\/tr><tr><td><code>SMSApi\/rateplan\/read<\/code><\/td><td>See its page<\/td><td><code>response.data<\/code><\/td><td><code>data<\/code> omitted<\/td><\/tr><tr><td><code>SMSApi\/senderid\/read<\/code><\/td><td>POST or GET<\/td><td><code>response.senderidList<\/code><\/td><td>Not shown<\/td><\/tr><tr><td><code>SMSApi\/template\/read<\/code><\/td><td>See its page<\/td><td><code>response.templateList<\/code><\/td><td>Not shown<\/td><\/tr><tr><td><code>rest\/rcs\/v1\/bots<\/code><\/td><td>GET<\/td><td><code>botsList<\/code><\/td><td><code>botsList<\/code> becomes <code>[]<\/code>, 403<\/td><\/tr><tr><td><code>rest\/rcs\/v1\/analytics<\/code><\/td><td>GET<\/td><td><code>analytics<\/code><\/td><td><code>analytics<\/code> becomes <code>[]<\/code><\/td><\/tr><tr><td><code>rest\/wa\/v1\/analytics<\/code><\/td><td>GET only<\/td><td><code>analyticsList<\/code><\/td><td><code>analyticsList<\/code> becomes <code>[]<\/code><\/td><\/tr><tr><td><code>rest\/tg\/v1\/templates<\/code><\/td><td>GET only<\/td><td>list payload<\/td><td>Payload key becomes <code>[]<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in that table decide how the harness is built.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The method sometimes selects the operation.<\/strong> The WhatsApp template endpoint <code>WAApi\/template<\/code> is method-keyed: GET lists, POST creates, DELETE removes. The page that describes reading templates says the endpoint accepts POST and GET, but POST on that path creates a template. The sender ID create endpoint <code>SMSApi\/senderid\/create<\/code> also accepts GET. A harness that allowlists paths but lets a developer change the method, or that retries a GET as a POST after a timeout, can create objects in your production account. The allowlist must hold exact path and method pairs, and it must refuse everything else. The PHP sample later shows one way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Authentication rules differ by family.<\/strong> On the <code>rest\/<\/code> families, <code>userid<\/code> is required even when the <code>apikey<\/code> header is present, and <code>output=json<\/code> is required. On the <code>SMSApi\/<\/code> family, the published tables describe <code>apiKey<\/code> as an alternative to <code>userid<\/code> and <code>password<\/code>. Send <code>userid<\/code> with the <code>apikey<\/code> header everywhere; it satisfies both families and keeps passwords out of your harness configuration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Some endpoints carry date limits that make a free error probe.<\/strong> RCS analytics accepts a maximum range of 31 days. WhatsApp analytics accepts a maximum of 365 days. A request that exceeds the range is a clean way to exercise the error branch without touching credentials, which is covered in the error-probe section below.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two families are deliberately left off the list. Send endpoints are excluded even with a test flag, because a wiring smoke test is a different job with different rails. Delivery report reads such as <code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> and <code>WAApi\/report<\/code> are read-only and free, but they return rows that depend on your traffic, which makes their fingerprints noisy on quiet days. Add them once the core harness is stable, and fingerprint only days that have rows.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"shape-fingerprint\" class=\"wp-block-heading\">Fingerprint the Shape, Ignore the Values<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A snapshot test compares values. It fails when a template is added, when a balance changes, when the count of response codes goes from 198 to 199. On a live account every one of those is normal, so a value snapshot either fails every night or gets muted, and a muted test is worse than none.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A fingerprint keeps only what should be stable: for every path in the document, the set of kinds observed at that path. Take the published sample for <code>SMSApi\/account\/readprofile<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"account\",\n    \"action\": \"readprofile\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 7,\n    \"account\": {\n      \"fullName\": \"Full Name\",\n      \"postalAddress\": \"Janakput\",\n      \"postalCity\": \"2707\",\n      \"postalCountry\": \"101\",\n      \"postalRegion\": \"22\",\n      \"profilePic\": \"\",\n      \"enableCMS\": \"1\"\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Its fingerprint is a sorted map from path to kinds:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$                              object\n$.response                     object\n$.response.account             object\n$.response.account.enableCMS   str:digits\n$.response.account.fullName    str\n$.response.account.postalAddress str\n$.response.account.postalCity  str:digits\n$.response.account.postalCountry str:digits\n$.response.account.postalRegion str:digits\n$.response.account.profilePic  str:empty\n$.response.action              str\n$.response.api                 str\n$.response.code                str:digits\n$.response.count               int\n$.response.msg                 str\n$.response.status              str<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Hash the sorted map and you have a single value that changes only when the shape changes. Store the map too, so a changed hash can be explained path by path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Four rules keep a fingerprint honest.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Arrays collapse to <code>[*]<\/code>.<\/strong> Every element contributes to the same path, so a list of fifty sender IDs and a list of three produce the same fingerprint, and a row that is shaped differently from the others adds a second kind at that path instead of hiding.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>An empty array is its own kind.<\/strong> A list that is empty tonight has no element paths. Without care, the differ reports every element path as removed. Mark an empty array as <code>array:empty<\/code> and treat its missing children as unobserved, not removed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keys are case-sensitive.<\/strong> The response code list wraps every row in a key named <code>errorcode<\/code> and names the inner field <code>errorCode<\/code>. A fingerprint that lower-cases keys would merge them and miss a rename.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Record kinds as sets.<\/strong> A path that is sometimes a string and sometimes a number is a finding in its own right. A set of two kinds at one path is how the fingerprint says &#8220;this field is a union&#8221;.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"lexical-classes\" class=\"wp-block-heading\">Lexical Classes: The Part Generic Drift Tools Miss<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most drift tools stop at JSON types: string, number, boolean, null, object, array. On a messaging API that misses the changes that break parsers, because the interesting information lives inside strings. <code>\"2707\"<\/code> in <code>postalCity<\/code> is an identifier, not a city name. <code>\"1\"<\/code> in <code>enableCMS<\/code> is a flag. <code>\"Pending\"<\/code> in <code>isEnabled<\/code> on the sender ID list is a status, not a boolean. A change from <code>\"1\"<\/code> to <code>\"true\"<\/code> in a flag is invisible to a type check and fatal to <code>== \"1\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A lexical class refines each scalar into a small, stable vocabulary. The set below covers every scalar in the published samples used in this guide.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Class<\/th><th>Rule<\/th><th>Example from a published sample<\/th><th>Why it matters<\/th><\/tr><\/thead><tbody><tr><td><code>str:empty<\/code><\/td><td>Empty string<\/td><td><code>profilePic: \"\"<\/code><\/td><td>Empty and absent are different; code that checks presence breaks<\/td><\/tr><tr><td><code>str:digits<\/code><\/td><td>Digits only<\/td><td><code>code: \"200\"<\/code>, <code>peId: \"0\"<\/code>, <code>postalCity: \"2707\"<\/code><\/td><td>Quoted numbers; a flip to unquoted breaks strict comparisons<\/td><\/tr><tr><td><code>str:decimalN<\/code><\/td><td>Digits, a point, exactly N decimals<\/td><td><code>rate<\/code> and <code>dltRate<\/code> on the rate plan, four decimals<\/td><td>Money as text; a change of N changes rounding<\/td><\/tr><tr><td><code>str:isodate<\/code><\/td><td>Starts <code>YYYY-MM-DD<\/code><\/td><td><code>fromDate: \"2026-07-11\"<\/code><\/td><td>Sortable, parseable<\/td><\/tr><tr><td><code>str:labeldate<\/code><\/td><td>Three-letter month, space, two digits<\/td><td><code>date: \"Jul 08\"<\/code> in RCS <code>dailyTrend<\/code><\/td><td>Not parseable without a year; breaks across new year<\/td><\/tr><tr><td><code>str:enum<\/code><\/td><td>Value in a small observed set for this path<\/td><td><code>status: \"success\"<\/code>, <code>status: \"FAILED\"<\/code><\/td><td>New members are semantic drift<\/td><\/tr><tr><td><code>str<\/code><\/td><td>Anything else<\/td><td><code>description<\/code>, <code>msg<\/code><\/td><td>Free text; never branch on it<\/td><\/tr><tr><td><code>int<\/code><\/td><td>Integer within the safe range<\/td><td><code>count: 198<\/code><\/td><td>Safe in every language<\/td><\/tr><tr><td><code>int:unsafe<\/code><\/td><td>Integer beyond 2^53 minus 1<\/td><td>Nineteen-digit <code>uuId<\/code> on WhatsApp report rows<\/td><td>Silently rounded by double-precision parsers<\/td><\/tr><tr><td><code>number:decimal<\/code><\/td><td>Number with a fraction<\/td><td>Two-decimal <code>charges<\/code> on Telegram rows<\/td><td>Floating point; compare with tolerance<\/td><\/tr><tr><td><code>bool<\/code>, <code>null<\/code><\/td><td>As JSON<\/td><td><\/td><td><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two of these deserve a closer look.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>int:unsafe<\/code>.<\/strong> <a href=\"https:\/\/www.rfc-editor.org\/info\/rfc8259\/\" target=\"_blank\" rel=\"noopener nofollow\">RFC 8259<\/a> says integers in the range from minus (2^53 minus 1) to 2^53 minus 1 are interoperable, because every implementation built on <a href=\"https:\/\/en.wikipedia.org\/wiki\/IEEE_754\" target=\"_blank\" rel=\"noopener nofollow\">IEEE 754<\/a> double precision agrees on them. Larger integers are legal JSON, but a parser may round them. JavaScript&#8217;s <code>JSON.parse<\/code> does, with no error. Transaction identifiers on this platform are eighteen or nineteen digits. Where they arrive quoted they are safe. Where a row carries them unquoted, as the WhatsApp report rows do for <code>uuId<\/code>, <code>mobileNo<\/code> and <code>wabaNumber<\/code>, a JavaScript consumer stores the wrong identifier and every later join against a delivery row fails. The harness must detect this on the raw text, before any parse, which the Node.js sample does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>str:enum<\/code>.<\/strong> Some string paths carry a closed vocabulary: <code>status<\/code> on envelopes, <code>status<\/code> and <code>identifier<\/code> on delivery code rows, <code>isEnabled<\/code> on sender rows. The harness learns the set per path from observation, and a new member is reported as a semantic change rather than a shape change. Do not seed these sets from guesses. Seed them from the first week of observations, then freeze them and let new members raise a ticket. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sender-identity-sms-whatsapp-rcs-telegram\/\">sender identity guide<\/a> uses the same fail-closed allowlist for <code>isEnabled<\/code> in production code; the harness is where that allowlist gets its early warning.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"probe-error-branch\" class=\"wp-block-heading\">Probe the Error Branch on Purpose<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A harness that only calls the success branch tests the half of the contract that rarely breaks your code. Parsers usually fail on the error branch, because that is where payload types change. Across the platform, three error behaviours appear in published samples:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Behaviour<\/th><th>Where it appears<\/th><th>What a naive typed client does<\/th><\/tr><\/thead><tbody><tr><td>The payload key keeps its name but becomes <code>[]<\/code><\/td><td><code>analytics<\/code> on RCS analytics, <code>botsList<\/code> on the RCS bot list, <code>analyticsList<\/code> on WhatsApp analytics, the payload key on every Telegram endpoint<\/td><td>Binds <code>[]<\/code> to an object type and throws, or treats &#8220;empty list&#8221; as &#8220;no bots&#8221; and deactivates routing<\/td><\/tr><tr><td>The payload key is omitted<\/td><td><code>data<\/code> on <code>SMSApi\/rateplan\/read<\/code><\/td><td>Null dereference on a path that always existed in testing<\/td><\/tr><tr><td>Only the status fields change<\/td><td>Envelopes whose error sample carries no payload at all<\/td><td>Works, until one of the other two behaviours arrives<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the RCS analytics pair, success and error, from its published samples:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"analytics\": {\n    \"todayMessages\": {\"total\": 40, \"success\": 38, \"read\": 20, \"failed\": 1, \"pending\": 0, \"notSent\": 1, \"others\": 0},\n    \"dailyTrend\": &#91;\n      {\"date\": \"Jul 08\", \"total\": 30, \"success\": 28, \"read\": 18, \"failed\": 1, \"notSent\": 1}\n    ]\n  },\n  \"fromDate\": \"2026-07-11\",\n  \"toDate\": \"2026-07-17\",\n  \"statusCode\": \"200\",\n  \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"error\",\n  \"analytics\": &#91;],\n  \"statusCode\": \"403\",\n  \"reason\": \"RCS API access is not enabled for this account.\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The same key, <code>analytics<\/code>, is an object on one branch and an empty array on the other. A fingerprint that merged the two branches would record <code>$.analytics<\/code> as <code>object | array:empty<\/code> and call it a union, which hides the rule that actually matters: the type is decided by <code>status<\/code>. So the harness keeps <strong>two fingerprints per endpoint<\/strong>, one per branch, and a response is filed under the branch its top-level <code>status<\/code> says it is on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is also the client rule the harness is protecting. Parse loosely, read only the top-level <code>status<\/code> at the right depth for the family, return early on anything other than success, and bind typed payloads only after that. Never branch on <code>code<\/code> or <code>statusCode<\/code>, and never parse <code>msg<\/code>, <code>reason<\/code> or <code>description<\/code>. The harness makes sure that rule keeps working by proving, every night, that <code>status<\/code> is still where you read it from.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How to provoke an error for free<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The cleanest error probe is a request that is valid for authentication and invalid for a parameter, because it exercises the error envelope without touching your credentials.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Free error probe<\/th><th>Why it is safe<\/th><\/tr><\/thead><tbody><tr><td><code>rest\/rcs\/v1\/analytics<\/code><\/td><td><code>fromDate<\/code> and <code>toDate<\/code> forty days apart<\/td><td>The published maximum range is 31 days<\/td><\/tr><tr><td><code>rest\/wa\/v1\/analytics<\/code><\/td><td>A range of more than 365 days<\/td><td>The published maximum range is 365 days<\/td><\/tr><tr><td><code>rest\/rcs\/v1\/analytics<\/code><\/td><td><code>action<\/code> set to a value outside <code>overview<\/code>, <code>today<\/code>, <code>messagebreakdown<\/code>, <code>trend<\/code><\/td><td>A read with no side effect whatever the outcome<\/td><\/tr><tr><td>Any <code>rest\/<\/code> read<\/td><td>Omit <code>output=json<\/code><\/td><td><code>output<\/code> is required on these families<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Treat the result as an observation, not a proof. If a range probe comes back as success because the provider clamps the range instead of rejecting it, record that as the observed contract for that probe and pick a different one. What you must not do is conclude that the error branch has the same shape as the success branch because your probe never reached it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Avoid bad-password probes as the default. They reach the error branch reliably, but lockout and alerting policies for repeated failed logins are not something you can read anywhere, and a harness that locks the production account at 02:00 has caused the incident it was built to prevent. If you want a credential-error fingerprint, run it against a separate sandbox login, such as one created from the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">demo environment<\/a>, once a week.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"code-catalogues\" class=\"wp-block-heading\">The Two Code Catalogues Are a Data Contract<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The response code list and the delivery code list are the most valuable things the harness reads, because they tell you about failure modes before any message hits them. They are also easy to read wrongly, because both samples wrap each row in an extra object.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API response error code list<\/a> at <code>SMSApi\/info\/responsecodes<\/code> accepts POST only and returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"info\",\n    \"action\": \"responsecodes\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 198,\n    \"responsecodesList\": &#91;\n      {\n        \"errorcode\": {\n          \"errorCode\": \"200\",\n          \"httpCode\": \"200\",\n          \"status\": \"success\",\n          \"description\": \"SenderId created successfully.\"\n        }\n      },\n      {\n        \"errorcode\": {\n          \"errorCode\": \"201\",\n          \"httpCode\": \"200\",\n          \"status\": \"error\",\n          \"description\": \"Given SenderId is already exists.\"\n        }\n      }\n    ]\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">delivery error code list<\/a> at <code>SMSApi\/info\/deliverycodes<\/code> also accepts POST only and returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"info\",\n    \"action\": \"deliverycodes\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 45,\n    \"deliverycodesList\": &#91;\n      {\n        \"errorcode\": {\n          \"peId\": \"0\",\n          \"identifier\": \"OTHER\",\n          \"status\": \"FAILED\",\n          \"cause\": \"Other\"\n        }\n      },\n      {\n        \"errorcode\": {\n          \"peId\": \"1\",\n          \"identifier\": \"SUCCESS\",\n          \"status\": \"DELIVERED\",\n          \"cause\": \"Delivered\"\n        }\n      }\n    ]\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Diffing the two samples against each other gives the reader&#8217;s checklist:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Property<\/th><th>Response code list<\/th><th>Delivery code list<\/th><th>Consequence<\/th><\/tr><\/thead><tbody><tr><td>Row path<\/td><td><code>response.responsecodesList[i].errorcode<\/code><\/td><td><code>response.deliverycodesList[i].errorcode<\/code><\/td><td>Both wrap the row; a flat reader gets an object where it expected a string<\/td><\/tr><tr><td>Wrapper key<\/td><td><code>errorcode<\/code>, lower case<\/td><td><code>errorcode<\/code>, lower case, on delivery rows too<\/td><td>The wrapper name does not follow the list name<\/td><\/tr><tr><td>Natural key<\/td><td><code>errorCode<\/code>, camel case, quoted digits<\/td><td><code>peId<\/code> quoted digits, plus <code>identifier<\/code> text<\/td><td>Key the delivery list on the pair<\/td><\/tr><tr><td>Status vocabulary<\/td><td><code>success<\/code>, <code>error<\/code><\/td><td><code>DELIVERED<\/code>, <code>FAILED<\/code> in the samples<\/td><td>Different case, different meaning<\/td><\/tr><tr><td><code>count<\/code><\/td><td>Integer, 198 in the sample<\/td><td>Integer, 45 in the sample<\/td><td>Row count; assert it equals the rows you parsed<\/td><\/tr><tr><td>HTTP code per row<\/td><td><code>httpCode<\/code>, quoted<\/td><td>Absent<\/td><td>Do not expect it on delivery rows<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The code catalogue is a response code list for the whole platform, not only for sending. The two sample rows are sender ID outcomes. That is useful: a single catalogue diff warns you about new outcomes on every family you call.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What to diff, row by row<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Load each catalogue into a map keyed by its natural key and compare it with yesterday&#8217;s map.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Added code.<\/strong> A new failure mode exists. Your error classifier falls through to its default for it. Open a ticket to classify it as permanent, transient or ambiguous, following the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">retry strategy guide<\/a>.<\/li>\n\n\n\n<li><strong>Removed code.<\/strong> Usually harmless. Keep its handler; old delivery rows and logs still carry it.<\/li>\n\n\n\n<li><strong>Status flipped<\/strong> between <code>success<\/code> and <code>error<\/code>, or between <code>DELIVERED<\/code> and <code>FAILED<\/code>. The most dangerous change on the list, because code that branched on the code number now routes a success as a failure or the reverse. Page someone.<\/li>\n\n\n\n<li><strong><code>httpCode<\/code> changed.<\/strong> Matters if your client reads the HTTP status before the body. Fail the release gate until reviewed.<\/li>\n\n\n\n<li><strong>Description or cause changed.<\/strong> Low severity for code, high value for support. Update any customer-facing text that quotes it.<\/li>\n\n\n\n<li><strong>Declared <code>count<\/code> differs from parsed rows.<\/strong> Either rows were dropped by your parser, or the list contains duplicate keys. Both need a look before you trust the diff.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The unknown-code default deserves a sentence of its own. A code your classifier has never seen must fall through to <strong>permanent<\/strong>, not transient. An unknown code treated as transient becomes a retry loop, and on a platform where billing fires at submission, each retry that is accepted is charged. Treated as permanent, it costs one undelivered message and a log line.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"classifying-diffs\" class=\"wp-block-heading\">Classifying a Diff: Additive, Narrowing, Type Flip, Semantic<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A raw diff is a list of path changes. The harness turns it into a decision by classifying each change into one of four kinds.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Kind<\/th><th>Examples<\/th><th>Breaks existing readers?<\/th><th>Default action<\/th><\/tr><\/thead><tbody><tr><td><strong>Additive<\/strong><\/td><td>A new key on rows; a new optional object; a new lexical class added to a path that already allowed free text<\/td><td>No, if your deserialiser ignores unknown properties<\/td><td>Log it. Review weekly. Consider adopting the field.<\/td><\/tr><tr><td><strong>Narrowing<\/strong><\/td><td>A path disappears; an array that always had elements is now empty on the success branch; a path that was sometimes present is now never present<\/td><td>Yes, for any reader of that path<\/td><td>Fail the release gate for code that reads the path<\/td><\/tr><tr><td><strong>Type flip<\/strong><\/td><td><code>\"200\"<\/code> becomes <code>200<\/code>; <code>str:digits<\/code> becomes <code>int:unsafe<\/code>; an object becomes <code>[]<\/code> on the success branch; <code>str:decimal4<\/code> becomes <code>str:decimal2<\/code><\/td><td>Yes, silently in loosely typed languages, loudly in strict ones<\/td><td>Page the owner. Parsers are at risk tonight.<\/td><\/tr><tr><td><strong>Semantic<\/strong><\/td><td>A new member of an observed enum; a catalogue code&#8217;s status flips; a date label format changes<\/td><td>Sometimes, and usually without an exception<\/td><td>Open a ticket; if it touches retry or routing, page<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three rules make the classification reliable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Classify per branch.<\/strong> An object becoming <code>[]<\/code> on the error branch is the known behaviour on several endpoints. The same change on the success branch is a type flip.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Unobserved is not removed.<\/strong> If an array is empty tonight, its element paths are unobserved. Only a path whose parent was observed with content and that is now missing counts as narrowing. This rule alone removes most of the false alarms a naive differ produces on a quiet account.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Widening a lexical class is additive only if you do not branch on it.<\/strong> A flag path moving from <code>str:digits<\/code> to <code>str<\/code> may mean <code>\"1\"<\/code> became <code>\"yes\"<\/code>. If your code compares that flag to <code>\"1\"<\/code>, mark the path as &#8220;branch-critical&#8221; in the ledger, and treat any change to its class as semantic.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"quirk-ledger\" class=\"wp-block-heading\">The Quirk Ledger: Pin Known Deviations as Expected<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every long-lived integration accumulates workarounds: coerce this code to a string, quote that identifier before parsing, treat this empty array as &#8220;no payload&#8221;. Each workaround encodes a belief about the provider. The quirk ledger writes those beliefs down in a form the harness can check.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># quirks.yaml, one entry per deviation your client code handles on purpose\n- id: rateplan-code-unquoted\n  endpoint: SMSApi\/rateplan\/read\n  branch: success\n  path: $.response.code\n  expect: int\n  workaround: client coerces code to string before logging\n  if_changed: harmless if coercion is idempotent; remove workaround after two weeks stable\n\n- id: rateplan-data-omitted-on-error\n  endpoint: SMSApi\/rateplan\/read\n  branch: error\n  path: $.response.data\n  expect: absent\n  workaround: client never reads data unless status is success\n\n- id: responsecodes-wrapper\n  endpoint: SMSApi\/info\/responsecodes\n  branch: success\n  path: $.response.responsecodesList&#91;*].errorcode.errorCode\n  expect: str:digits\n  workaround: reader unwraps errorcode, reads errorCode\n  if_changed: catalogue reader breaks; page\n\n- id: deliverycodes-peid-quoted\n  endpoint: SMSApi\/info\/deliverycodes\n  branch: success\n  path: $.response.deliverycodesList&#91;*].errorcode.peId\n  expect: str:digits\n\n- id: rcs-analytics-error-empty-array\n  endpoint: rest\/rcs\/v1\/analytics\n  branch: error\n  path: $.analytics\n  expect: array:empty\n\n- id: rcs-analytics-label-date\n  endpoint: rest\/rcs\/v1\/analytics\n  branch: success\n  path: $.analytics.dailyTrend&#91;*].date\n  expect: str:labeldate\n  workaround: year inferred from fromDate and toDate\n  if_changed: if ISO dates arrive, drop the inference; do not run both\n\n- id: rcs-bots-error-empty-array\n  endpoint: rest\/rcs\/v1\/bots\n  branch: error\n  path: $.botsList\n  expect: array:empty\n  workaround: routing never deactivates bots on a non-success read\n\n- id: profile-city-is-an-id\n  endpoint: SMSApi\/account\/readprofile\n  branch: success\n  path: $.response.account.postalCity\n  expect: str:digits\n  workaround: display layer resolves the id; never shows it raw<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each entry is an assertion the harness runs in addition to the fingerprint diff, and it cuts both ways.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>If the quirk is still there, the ledger silences the alert.<\/strong> The error-branch <code>[]<\/code> on RCS analytics is expected, so the harness does not page anyone for it every night.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>If the quirk disappears, the ledger raises one.<\/strong> This is the case generic drift tools cannot see. Suppose the rate plan starts returning <code>code<\/code> as <code>\"200\"<\/code>. A fingerprint diff reports a type flip, which is correct. The ledger adds the more useful part: which workaround depended on the old behaviour, and what to do about it. For an idempotent coercion, nothing. For a workaround that is not idempotent, such as a pre-parse step that wraps a bare number in quotes, a provider fix can turn <code>\"123\"<\/code> into <code>\"\"123\"\"<\/code> and break the parse outright. The Node.js sample below is written so that it cannot do that, but many hand-rolled versions can.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the ledger in the same repository as the client code, and review it in the same pull request as any workaround change. A workaround without a ledger entry is a belief nobody is checking.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"harness-architecture\" class=\"wp-block-heading\">Harness Architecture<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-contract-harness-pipeline.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-contract-harness-pipeline.svg\" alt=\"Three-stage pipeline: probe read-only endpoints on success and error branches, fingerprint and diff the raw responses, then triage each change against the quirk ledger and route it by severity.\" class=\"wp-image-3052\"\/><\/a><figcaption class=\"wp-element-caption\">The harness never calls a billable or mutating endpoint. It compares shapes and lexical classes, not values, and treats a vanished quirk as drift.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The harness is three small stages with a store between them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Stage one, probe.<\/strong> A scheduler walks a probe list. Each probe names an endpoint, a method, a parameter set and the branch it expects to reach. The HTTP client refuses any path and method pair not on the allowlist, sets a short timeout, never retries on its own, and saves the raw response body, the HTTP status and the response headers exactly as received. Raw bodies are the evidence you will want when you explain a change to someone else, so keep them for at least thirty days.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Stage two, fingerprint and diff.<\/strong> The raw body is scanned for long unquoted integers before any parse, then parsed into a structure that keeps integers exact, then reduced to a fingerprint per branch. The fingerprint is compared with the last stored fingerprint for the same endpoint and branch. Separately, the two catalogues are loaded into keyed maps and diffed row by row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Stage three, triage and route.<\/strong> Each change is matched against the quirk ledger, classified as additive, narrowing, type flip or semantic, and routed: logged, gate-failing, paging or ticketed. The run writes a single summary record with counts per kind, which is what your dashboards read.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two design choices keep it cheap to run.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One run, one process, no concurrency.<\/strong> A dozen sequential read calls take seconds. Concurrency adds nothing and makes the raw evidence harder to line up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The harness owns no credentials of its own beyond read access.<\/strong> Give it a dedicated API key if your account supports several, keep it in the same secret store as production keys, and rotate it on the same schedule. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/oauth-messaging-connect-customer-sms-account\/\">OAuth guide<\/a> covers the token lifetimes if you run the harness against customer accounts connected through OAuth.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"python-fingerprinter\" class=\"wp-block-heading\">Python: The Fingerprinter and the Differ<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The trap this sample shows: treating an empty array as &#8220;every child path was removed&#8221;.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Python&#8217;s <code>json<\/code> module keeps integers exact at any length, so the big-integer problem does not bite here. The fingerprinter still has to classify long integers as unsafe, because the fingerprint is shared with consumers in other languages that do lose precision.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># fingerprint.py\nimport hashlib\nimport json\nimport re\nfrom decimal import Decimal\n\nSAFE_MAX = 2**53 - 1\nDIGITS = re.compile(r\"^\\d+$\")\nDECIMAL = re.compile(r\"^-?\\d+\\.(\\d+)$\")\nISO_DATE = re.compile(r\"^\\d{4}-\\d{2}-\\d{2}\")\nLABEL_DATE = re.compile(r\"^&#91;A-Z]&#91;a-z]{2} \\d{2}$\")\n\n\ndef lexical(value):\n    if value is None:\n        return \"null\"\n    if isinstance(value, bool):          # bool before int: True is an int in Python\n        return \"bool\"\n    if isinstance(value, int):\n        return \"int\" if abs(value) &lt;= SAFE_MAX else \"int:unsafe\"\n    if isinstance(value, Decimal):\n        return \"number:decimal\"\n    if isinstance(value, str):\n        if value == \"\":\n            return \"str:empty\"\n        if DIGITS.match(value):\n            return \"str:digits\"\n        m = DECIMAL.match(value)\n        if m:\n            return f\"str:decimal{len(m.group(1))}\"\n        if ISO_DATE.match(value):\n            return \"str:isodate\"\n        if LABEL_DATE.match(value):\n            return \"str:labeldate\"\n        return \"str\"\n    return type(value).__name__\n\n\ndef walk(node, path, out, empty_arrays):\n    if isinstance(node, dict):\n        out.setdefault(path, set()).add(\"object\")\n        for key, child in node.items():\n            walk(child, f\"{path}.{key}\", out, empty_arrays)\n    elif isinstance(node, list):\n        if not node:\n            out.setdefault(path, set()).add(\"array:empty\")\n            empty_arrays.add(path)\n        else:\n            out.setdefault(path, set()).add(\"array\")\n            for item in node:\n                walk(item, f\"{path}&#91;*]\", out, empty_arrays)\n    else:\n        out.setdefault(path, set()).add(lexical(node))\n\n\ndef fingerprint(raw_text: str):\n    body = json.loads(raw_text, parse_float=Decimal)\n    out, empty_arrays = {}, set()\n    walk(body, \"$\", out, empty_arrays)\n    canon = {p: sorted(k) for p, k in sorted(out.items())}\n    digest = hashlib.sha256(json.dumps(canon, sort_keys=True).encode()).hexdigest()\n    return canon, digest, sorted(empty_arrays)\n\n\ndef branch_of(body: dict) -&gt; str:\n    \"\"\"Read status at the right depth: nested for SMSApi, top level for rest\/.\"\"\"\n    status = body.get(\"response\", {}).get(\"status\") if \"response\" in body else body.get(\"status\")\n    return \"success\" if status == \"success\" else \"error\"\n\n\ndef diff(old: dict, new: dict, new_empty_arrays: list):\n    \"\"\"Return (kind, path, old_kinds, new_kinds). Children of an empty array are unobserved.\"\"\"\n    def unobserved(path):\n        return any(path.startswith(a + \"&#91;*]\") for a in new_empty_arrays)\n\n    changes = &#91;]\n    for path in sorted(old.keys() - new.keys()):\n        if not unobserved(path):\n            changes.append((\"removed\", path, old&#91;path], None))\n    for path in sorted(new.keys() - old.keys()):\n        changes.append((\"added\", path, None, new&#91;path]))\n    for path in sorted(old.keys() &amp; new.keys()):\n        if old&#91;path] != new&#91;path]:\n            changes.append((\"kinds_changed\", path, old&#91;path], new&#91;path]))\n    return changes<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run against the profile sample, <code>fingerprint<\/code> produces the table shown in the fingerprint section, with <code>count<\/code> as <code>int<\/code>, <code>code<\/code> and <code>postalCity<\/code> as <code>str:digits<\/code> and <code>profilePic<\/code> as <code>str:empty<\/code>. Run against the RCS analytics success sample, <code>dailyTrend[*].date<\/code> comes out as <code>str:labeldate<\/code> while <code>fromDate<\/code> comes out as <code>str:isodate<\/code>, which is exactly the distinction a year-inference workaround needs protected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two lines in that code are there because of real behaviour. The <code>bool<\/code> check comes before the <code>int<\/code> check because <code>isinstance(True, int)<\/code> is true in Python, and a flag would otherwise be fingerprinted as a number. And <code>branch_of<\/code> looks for <code>response<\/code> first because the <code>SMSApi\/<\/code> family nests its envelope while the <code>rest\/<\/code> families do not; reading <code>status<\/code> at the wrong depth files every nested response under &#8220;error&#8221;.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"nodejs-big-numbers\" class=\"wp-block-heading\">Node.js: Catch Nineteen-Digit Numbers Before JSON.parse<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The trap this sample shows: <code>JSON.parse<\/code> rounds large integers without raising an error, so the evidence is gone before your code sees it.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ longnumbers.mjs\nconst MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);\n\n\/\/ Tokenizer-aware scan: skips string contents, so digits inside \"msg\" are ignored.\nexport function findUnsafeIntegers(raw) {\n  const hits = &#91;];\n  let inString = false;\n  let escaped = false;\n  for (let i = 0; i &lt; raw.length; i++) {\n    const c = raw&#91;i];\n    if (inString) {\n      if (escaped) escaped = false;\n      else if (c === \"\\\\\") escaped = true;\n      else if (c === '\"') inString = false;\n      continue;\n    }\n    if (c === '\"') { inString = true; continue; }\n    if (c === \"-\" || (c &gt;= \"0\" &amp;&amp; c &lt;= \"9\")) {\n      let j = i;\n      while (j &lt; raw.length &amp;&amp; \/&#91;-+0-9.eE]\/.test(raw&#91;j])) j++;\n      const token = raw.slice(i, j);\n      if (\/^-?\\d+$\/.test(token)) {\n        const n = BigInt(token);\n        if (n &gt; MAX_SAFE || n &lt; -MAX_SAFE) hits.push({ offset: i, token });\n      }\n      i = j - 1;\n    }\n  }\n  return hits;\n}\n\n\/\/ Wrap unsafe integers in quotes. Idempotent: an already quoted value sits\n\/\/ inside a string, so the scanner never sees it and never double-quotes it.\nexport function quoteUnsafeIntegers(raw) {\n  const hits = findUnsafeIntegers(raw);\n  let out = \"\";\n  let last = 0;\n  for (const h of hits) {\n    out += raw.slice(last, h.offset) + `\"${h.token}\"`;\n    last = h.offset + h.token.length;\n  }\n  return { text: out + raw.slice(last), unsafe: hits.map((h) =&gt; h.token) };\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ probe-report.mjs: how the harness uses it\nimport { quoteUnsafeIntegers } from \".\/longnumbers.mjs\";\n\nexport function parseForHarness(raw) {\n  const { text, unsafe } = quoteUnsafeIntegers(raw);\n  const body = JSON.parse(text);\n  \/\/ The fingerprint records these paths as int:unsafe, not str:digits,\n  \/\/ so a provider change from unquoted to quoted still shows up as a diff.\n  return { body, unsafeTokens: new Set(unsafe) };\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three properties matter more than the code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>It scans outside strings only.<\/strong> A regex over the whole body will eventually match digits inside a message text and rewrite user content. The tokenizer skips string contents, including escaped quotes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>It is idempotent.<\/strong> Run it on a body where the provider has already quoted the identifier and it changes nothing. That is the property that makes a provider fix harmless, and it is the one the quirk ledger entry for pre-parse quoting relies on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>It reports, it does not hide.<\/strong> The harness records the tokens it quoted, so the fingerprint can still say <code>int:unsafe<\/code> for those paths. If the harness quoted silently, the day the provider fixed the field would look like no change at all, and you would never know you could delete the workaround.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same helper belongs in production code for any row that can carry a long identifier, such as WhatsApp report rows. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp wire contract guide<\/a> shows where those rows come from and which fields to store as text.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"java-catalogue-diff\" class=\"wp-block-heading\">Java: A Catalogue Diff That Fails Closed<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The trap this sample shows: the wrapper key and the inner key differ only in case, and a map keyed by code silently collapses duplicates.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import com.fasterxml.jackson.databind.JsonNode;\nimport java.util.*;\n\npublic final class CatalogueDiff {\n\n    public record Row(String key, String httpCode, String status, String text) {}\n    public enum Kind { ADDED, REMOVED, STATUS_FLIPPED, HTTP_CODE_CHANGED, TEXT_CHANGED }\n    public record Change(Kind kind, String key, Row before, Row after) {}\n\n    public static final class ShapeDrift extends RuntimeException {\n        public ShapeDrift(String m) { super(m); }\n    }\n\n    \/** SMSApi\/info\/responsecodes: response.responsecodesList&#91;i].errorcode.errorCode *\/\n    public static Map&lt;String, Row&gt; readResponseCodes(JsonNode root) {\n        JsonNode resp = root.path(\"response\");\n        if (!\"success\".equals(resp.path(\"status\").asText())) {\n            throw new ShapeDrift(\"responsecodes: status is not success\");\n        }\n        Map&lt;String, Row&gt; out = new TreeMap&lt;&gt;();\n        int rows = 0;\n        for (JsonNode wrapper : resp.path(\"responsecodesList\")) {\n            rows++;\n            JsonNode row = wrapper.path(\"errorcode\");            \/\/ wrapper, lower case\n            if (row.isMissingNode()) throw new ShapeDrift(\"row without errorcode wrapper\");\n            String code = row.path(\"errorCode\").asText(null);    \/\/ inner, camel case\n            if (code == null || code.isEmpty()) throw new ShapeDrift(\"errorcode.errorCode missing\");\n            out.put(code, new Row(code, row.path(\"httpCode\").asText(\"\"),\n                    row.path(\"status\").asText(\"\"), row.path(\"description\").asText(\"\")));\n        }\n        checkCount(resp, rows, out.size(), \"responsecodes\");\n        return out;\n    }\n\n    \/** SMSApi\/info\/deliverycodes: key on peId plus identifier, not on either alone. *\/\n    public static Map&lt;String, Row&gt; readDeliveryCodes(JsonNode root) {\n        JsonNode resp = root.path(\"response\");\n        if (!\"success\".equals(resp.path(\"status\").asText())) {\n            throw new ShapeDrift(\"deliverycodes: status is not success\");\n        }\n        Map&lt;String, Row&gt; out = new TreeMap&lt;&gt;();\n        int rows = 0;\n        for (JsonNode wrapper : resp.path(\"deliverycodesList\")) {\n            rows++;\n            JsonNode row = wrapper.path(\"errorcode\");\n            String key = row.path(\"peId\").asText(\"\") + \"|\" + row.path(\"identifier\").asText(\"\");\n            out.put(key, new Row(key, \"\", row.path(\"status\").asText(\"\"), row.path(\"cause\").asText(\"\")));\n        }\n        checkCount(resp, rows, out.size(), \"deliverycodes\");\n        return out;\n    }\n\n    private static void checkCount(JsonNode resp, int rows, int unique, String name) {\n        int declared = resp.path(\"count\").asInt(-1);             \/\/ integer in both samples\n        if (declared != rows) throw new ShapeDrift(name + \": count \" + declared + \" but \" + rows + \" rows\");\n        if (unique != rows) throw new ShapeDrift(name + \": duplicate keys, \" + (rows - unique) + \" collapsed\");\n    }\n\n    public static List&lt;Change&gt; diff(Map&lt;String, Row&gt; before, Map&lt;String, Row&gt; after) {\n        List&lt;Change&gt; changes = new ArrayList&lt;&gt;();\n        Set&lt;String&gt; keys = new TreeSet&lt;&gt;(before.keySet());\n        keys.addAll(after.keySet());\n        for (String k : keys) {\n            Row b = before.get(k), a = after.get(k);\n            if (b == null) changes.add(new Change(Kind.ADDED, k, null, a));\n            else if (a == null) changes.add(new Change(Kind.REMOVED, k, b, null));\n            else {\n                if (!b.status().equalsIgnoreCase(a.status())) changes.add(new Change(Kind.STATUS_FLIPPED, k, b, a));\n                if (!b.httpCode().equals(a.httpCode())) changes.add(new Change(Kind.HTTP_CODE_CHANGED, k, b, a));\n                if (!b.text().equals(a.text())) changes.add(new Change(Kind.TEXT_CHANGED, k, b, a));\n            }\n        }\n        return changes;\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three choices in that code are the point.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>path()<\/code> rather than <code>get()<\/code>.<\/strong> Jackson&#8217;s <code>path<\/code> returns a missing node instead of null, so the reader can say exactly which level of the wrapper disappeared.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two separate count checks.<\/strong> <code>count<\/code> against rows tells you the list is complete. Unique keys against rows tells you the <code>TreeMap<\/code> did not silently overwrite a duplicate. A catalogue with two rows for the same code is a finding in its own right, because your classifier can only hold one meaning for it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The diff fails closed.<\/strong> Any <code>ShapeDrift<\/code> stops the catalogue comparison for the night and raises an alert, rather than diffing a half-read list and reporting a hundred removed codes. Downstream, a new code reaches your error classifier&#8217;s default, and that default must be the permanent, do-not-retry path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-java-spring-boot-integration\/\">Java and Spring Boot integration guide<\/a> covers configuring the production deserialiser to ignore unknown properties, which is what makes additive changes harmless in the first place.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"php-allowlist\" class=\"wp-block-heading\">PHP: An Allowlist That Makes Writes Impossible<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The trap this sample shows: on this platform the HTTP method can select the operation, so a path allowlist without methods still lets a harness create objects.<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nfinal class ReadOnlyProbe\n{\n    private const BASE = 'https:\/\/unify.smsgateway.center\/';\n\n    \/** Exact path =&gt; allowed methods. Anything else is refused before any I\/O. *\/\n    private const ALLOW = &#91;\n        'SMSApi\/info\/responsecodes'  =&gt; &#91;'POST'],\n        'SMSApi\/info\/deliverycodes'  =&gt; &#91;'POST'],\n        'SMSApi\/account\/readprofile' =&gt; &#91;'POST', 'GET'],\n        'SMSApi\/senderid\/read'       =&gt; &#91;'POST', 'GET'],\n        'rest\/rcs\/v1\/bots'           =&gt; &#91;'GET'],\n        'rest\/rcs\/v1\/analytics'      =&gt; &#91;'GET'],\n        'rest\/wa\/v1\/analytics'       =&gt; &#91;'GET'],\n        'rest\/tg\/v1\/templates'       =&gt; &#91;'GET'],\n    ];\n\n    public function __construct(private string $userid, private string $apikey) {}\n\n    \/** @return array{http:int, raw:string, body:mixed} *\/\n    public function call(string $path, string $method, array $params): array\n    {\n        $method = strtoupper($method);\n        if (!isset(self::ALLOW&#91;$path]) || !in_array($method, self::ALLOW&#91;$path], true)) {\n            throw new LogicException(\"Refusing {$method} {$path}: not on the read-only allowlist\");\n        }\n        $params = &#91;'userid' =&gt; $this-&gt;userid, 'output' =&gt; 'json'] + $params;\n\n        $ch = curl_init();\n        $url = self::BASE . $path;\n        if ($method === 'GET') {\n            $url .= '?' . http_build_query($params);\n        } else {\n            curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));\n        }\n        curl_setopt_array($ch, &#91;\n            CURLOPT_URL            =&gt; $url,\n            CURLOPT_CUSTOMREQUEST  =&gt; $method,\n            CURLOPT_RETURNTRANSFER =&gt; true,\n            CURLOPT_TIMEOUT        =&gt; 15,\n            CURLOPT_FOLLOWLOCATION =&gt; false,   \/\/ a redirect must not turn a GET into anything else\n            CURLOPT_HTTPHEADER     =&gt; &#91;'apikey: ' . $this-&gt;apikey, 'Accept: application\/json'],\n        ]);\n        $raw = curl_exec($ch);\n        $http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);\n        curl_close($ch);\n        if ($raw === false) {\n            throw new RuntimeException(\"Transport failure on {$path}; no retry by design\");\n        }\n\n        \/\/ Keep long integers as strings instead of floats.\n        $body = json_decode($raw, true, 512, JSON_BIGINT_AS_STRING);\n        return &#91;'http' =&gt; $http, 'raw' =&gt; $raw, 'body' =&gt; $body];\n    }\n}\n\n\/\/ Error-branch probe: a range longer than the published 31-day maximum.\n$probe = new ReadOnlyProbe(getenv('SGC_USERID'), getenv('SGC_APIKEY'));\n$r = $probe-&gt;call('rest\/rcs\/v1\/analytics', 'GET', &#91;\n    'action'   =&gt; 'overview',\n    'fromDate' =&gt; date('Y-m-d', strtotime('-40 days')),\n    'toDate'   =&gt; date('Y-m-d'),\n]);\nfile_put_contents(sprintf('snapshots\/raw\/rcs-analytics-error-%s.json', date('Ymd')), $r&#91;'raw']);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The allowlist lives in a constant, not in configuration, on purpose. A constant can only change through a code review. Note also what is missing from it: <code>WAApi\/template<\/code>, because the same path creates with POST and a slip in one probe definition would create a template in production; <code>SMSApi\/senderid\/create<\/code>, which also accepts GET; and every send endpoint. <code>CURLOPT_FOLLOWLOCATION<\/code> is off because a redirect is not something a read-only probe should follow blindly, and there is no automatic retry, because a probe that fails tonight is information, not a problem to paper over.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>JSON_BIGINT_AS_STRING<\/code> keeps a nineteen-digit identifier as text rather than converting it to a float. Without the flag, PHP turns an integer that overflows its native size into a float and loses the low digits, the same failure as JavaScript by a different route.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"snapshots-alerting\" class=\"wp-block-heading\">Storing Snapshots and Alerting Without Noise<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The store is small and boring on purpose. Three tables cover it.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Table<\/th><th>One row per<\/th><th>Columns<\/th><\/tr><\/thead><tbody><tr><td><code>probe_run<\/code><\/td><td>Harness run<\/td><td><code>run_id<\/code>, <code>started_at<\/code>, <code>finished_at<\/code>, <code>probes_ok<\/code>, <code>probes_failed<\/code>, <code>changes_additive<\/code>, <code>changes_narrowing<\/code>, <code>changes_type_flip<\/code>, <code>changes_semantic<\/code><\/td><\/tr><tr><td><code>fingerprint<\/code><\/td><td>Endpoint, branch and run<\/td><td><code>run_id<\/code>, <code>endpoint<\/code>, <code>branch<\/code>, <code>digest<\/code>, <code>paths_json<\/code>, <code>raw_body_ref<\/code>, <code>http_status<\/code><\/td><\/tr><tr><td><code>catalogue_row<\/code><\/td><td>Catalogue, key and run<\/td><td><code>run_id<\/code>, <code>catalogue<\/code>, <code>row_key<\/code>, <code>status<\/code>, <code>http_code<\/code>, <code>text<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Store the digest and the full path map. The digest makes &#8220;did anything change&#8221; a single comparison; the path map makes &#8220;what changed&#8221; answerable without reprocessing raw bodies. Keep raw bodies in object storage and reference them, because they are the evidence for every alert.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Alerting rules that keep the channel quiet enough to be read:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Compare against the last run on the same branch that succeeded as a probe<\/strong>, not against the last run. A night where the network failed produces no fingerprint and must not produce a thousand &#8220;removed&#8221; findings the next morning.<\/li>\n\n\n\n<li><strong>Alert on digest change, not on every run.<\/strong> A stable API should produce zero messages for weeks.<\/li>\n\n\n\n<li><strong>Require two consecutive observations for narrowing on list endpoints.<\/strong> A row type that appears only occasionally, such as a sender in a rare status, will vanish and reappear. Additive findings need one observation; narrowing needs two.<\/li>\n\n\n\n<li><strong>Never alert on counts.<\/strong> <code>count<\/code> on the catalogues, <code>total<\/code> on analytics and the number of templates are values. The only count check that matters is declared count against parsed rows within the same response.<\/li>\n\n\n\n<li><strong>Put the probe&#8217;s own health on the dashboard.<\/strong> A harness that has silently failed for a week looks exactly like an API that has not changed for a week. Graph <code>probes_ok<\/code> and alert when it drops, the same way the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability guide for messaging pipelines<\/a> treats any silent pipeline.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"endpoint-lessons\" class=\"wp-block-heading\">What Each Endpoint Teaches the Harness<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-endpoint-drift-signals.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-endpoint-drift-signals.svg\" alt=\"Table of six read-only endpoints comparing envelope, status field, meaning of count, error payload and the quirk to pin for each.\" class=\"wp-image-3053\"\/><\/a><figcaption class=\"wp-element-caption\">One platform, three envelope conventions and several error-payload behaviours. Fingerprint each endpoint on both branches.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Six endpoints give the harness most of its coverage. Each one teaches a different rule.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Envelope<\/th><th>Status field<\/th><th><code>count<\/code> means<\/th><th>Error payload<\/th><th>Quirk to pin<\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/info\/responsecodes<\/code><\/td><td>Nested under <code>response<\/code><\/td><td><code>code<\/code>, quoted <code>\"200\"<\/code><\/td><td>Row count<\/td><td>Not in the published sample<\/td><td>Wrapper <code>errorcode<\/code>, inner <code>errorCode<\/code><\/td><\/tr><tr><td><code>SMSApi\/info\/deliverycodes<\/code><\/td><td>Nested under <code>response<\/code><\/td><td><code>code<\/code>, quoted <code>\"200\"<\/code><\/td><td>Row count<\/td><td>Not in the published sample<\/td><td><code>peId<\/code> is a quoted string<\/td><\/tr><tr><td><code>SMSApi\/rateplan\/read<\/code><\/td><td>Nested under <code>response<\/code><\/td><td><code>code<\/code>, unquoted <code>200<\/code><\/td><td>Not present<\/td><td><code>data<\/code> omitted<\/td><td><code>rate<\/code> and <code>dltRate<\/code> are four-decimal strings<\/td><\/tr><tr><td><code>SMSApi\/account\/readprofile<\/code><\/td><td>Nested under <code>response<\/code><\/td><td><code>code<\/code>, quoted <code>\"200\"<\/code><\/td><td>Key count of <code>account<\/code> (7)<\/td><td>Not in the published sample<\/td><td>Identifiers in name-like fields (<code>postalCity<\/code>)<\/td><\/tr><tr><td><code>rest\/rcs\/v1\/analytics<\/code><\/td><td>Flat<\/td><td><code>statusCode<\/code>, quoted <code>\"200\"<\/code><\/td><td>Not present<\/td><td><code>analytics<\/code> becomes <code>[]<\/code><\/td><td><code>dailyTrend[*].date<\/code> is a label with no year<\/td><\/tr><tr><td><code>rest\/rcs\/v1\/bots<\/code><\/td><td>Flat<\/td><td>Top-level <code>status<\/code><\/td><td>Not present<\/td><td><code>botsList<\/code> becomes <code>[]<\/code>, 403<\/td><td>Only active bots are returned<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The rows that follow from that table are rules you can put straight into code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>count<\/code> has no single meaning.<\/strong> On the catalogues it is the number of rows. On the profile it is the number of keys in <code>account<\/code>. Elsewhere on the platform it is an object of <code>total<\/code> and <code>current<\/code> on delivery reports, and an object of <code>total<\/code>, <code>pending<\/code>, <code>active<\/code> and <code>rejected<\/code> on the sender ID list. The fingerprint records its kind per endpoint; your code should never read <code>count<\/code> through a shared helper.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>code<\/code> and <code>statusCode<\/code> are for logs, not for branching.<\/strong> They are quoted on some endpoints and not on others, and they sit at different depths. The harness pins their kinds so a change is visible, but client code should branch on <code>status<\/code> only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rows with sibling objects are not uniform.<\/strong> RCS analytics <code>todayMessages<\/code> carries <code>pending<\/code> and <code>others<\/code>, while <code>dailyTrend<\/code> rows omit both. A shared row type for &#8220;a volume bucket&#8221; will either fail or fill zeros that were never sent. Fingerprint the two paths separately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A read that lists &#8220;active&#8221; objects is not a registry.<\/strong> The RCS bot list returns active bots only. If the list shrinks, the fingerprint does not change at all. That is correct for the harness, and it is why routing code must snapshot the list daily rather than treat it as history, as the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-messaging-api-reference-migration-from-sms\/\">RCS API reference<\/a> and the sender identity guide both recommend.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The same entity changes spelling across endpoints.<\/strong> A <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-api\/\">WhatsApp number<\/a> is <code>wabaNumber<\/code> on report rows, <code>wabaNumber<\/code> quoted on inbox rows and <code>waNumber<\/code> on analytics rows. Add the inbox and analytics reads to the probe list if you consume them, and pin each spelling separately; the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">four-channel inbox guide<\/a> lists the inbox shapes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Extend the list the same way for templates. <code>SMSApi\/template\/read<\/code> rows sit at <code>response.templateList[i].template<\/code>, while <code>RCSApi\/template\/list<\/code> returns a bare <code>{\"templates\":[...]}<\/code> with no status envelope at all, so its branch has to be inferred from the presence of <code>templates<\/code> rather than from <code>status<\/code>. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-template-management-four-channels\/\">template management guide<\/a> covers both shapes and the Telegram and WhatsApp equivalents.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ci-release-gate\" class=\"wp-block-heading\">Wiring It Into CI and the Release Gate<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The harness has two jobs in a delivery pipeline, and they run on different triggers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nightly, against production, as a monitor.<\/strong> This is the run described so far. It reports to a channel, pages on type flips and status flips, and opens tickets for semantic findings. It never blocks anything, because there is nothing to block: the change has already shipped on the provider&#8217;s side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>On every release of your own client, as a gate.<\/strong> Before you deploy a new version of your messaging client, run a short job that loads the latest stored fingerprints and checks them against the paths your new code reads. If your release reads a path the harness has never observed, or reads a path whose last observed kind differs from what your parser expects, the gate fails. This is the consumer half of a consumer-driven contract, run against real observations instead of a provider build.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># .github\/workflows\/messaging-contract.yml\nname: messaging-contract\non:\n  schedule:\n    - cron: \"15 1 * * *\"      # nightly monitor\n  pull_request:\n    paths: &#91;\"messaging\/**\", \"quirks.yaml\"]\njobs:\n  monitor:\n    if: github.event_name == 'schedule'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - run: python -m harness.run --probes probes.yaml --ledger quirks.yaml --store \"$STORE_URL\"\n        env:\n          SGC_USERID: ${{ secrets.SGC_USERID }}\n          SGC_APIKEY: ${{ secrets.SGC_READONLY_APIKEY }}\n          STORE_URL: ${{ secrets.HARNESS_STORE_URL }}\n  gate:\n    if: github.event_name == 'pull_request'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - run: python -m harness.gate --reads messaging\/reads.yaml --ledger quirks.yaml --store \"$STORE_URL\"\n        env:\n          STORE_URL: ${{ secrets.HARNESS_STORE_URL }}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The gate needs one extra artefact: <code>reads.yaml<\/code>, a list of every path your client reads and the kind it expects. Keep it next to the parser and update it in the same pull request as any parser change. The gate job needs no API credentials at all, because it reads stored fingerprints. That keeps pull requests from forks away from your keys.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the harness out of the per-commit test suite. Unit tests with transport mocks run on every commit and cost nothing; the harness makes real calls and belongs on a schedule. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/\">four-layer testing guide<\/a> covers where mocks and fixtures fit, and the fixtures it recommends are the raw bodies this harness already stores.<\/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 Check Catches Which Drift<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Drift<\/th><th>Type-only schema check<\/th><th>Value snapshot<\/th><th>Shape fingerprint with lexical classes<\/th><th>Error-branch probe<\/th><th>Catalogue row diff<\/th><th>Quirk ledger<\/th><\/tr><\/thead><tbody><tr><td><code>\"200\"<\/code> becomes <code>200<\/code><\/td><td>No, if both map to &#8220;scalar&#8221;<\/td><td>Yes, plus endless noise<\/td><td>Yes<\/td><td>Only on that branch<\/td><td>No<\/td><td>Yes, if pinned<\/td><\/tr><tr><td>A nineteen-digit identifier arrives unquoted<\/td><td>No<\/td><td>No<\/td><td>Yes, as <code>int:unsafe<\/code> on the raw text<\/td><td>No<\/td><td>No<\/td><td>Yes, if pinned<\/td><\/tr><tr><td>An object becomes <code>[]<\/code> on the error branch<\/td><td>Only if the error branch is probed<\/td><td>Yes, with noise<\/td><td>Yes, per branch<\/td><td>Yes<\/td><td>No<\/td><td>Yes, silences the known case<\/td><\/tr><tr><td>A key disappears from rows<\/td><td>Yes<\/td><td>Yes, with noise<\/td><td>Yes, if rows were observed<\/td><td>No<\/td><td>No<\/td><td>No<\/td><\/tr><tr><td>A new failure code appears<\/td><td>No<\/td><td>Yes, with noise<\/td><td>No<\/td><td>No<\/td><td>Yes<\/td><td>No<\/td><\/tr><tr><td>A code flips from success to error<\/td><td>No<\/td><td>Yes, with noise<\/td><td>No<\/td><td>No<\/td><td>Yes<\/td><td>No<\/td><\/tr><tr><td>A date label changes format<\/td><td>No<\/td><td>Yes, with noise<\/td><td>Yes, via lexical class<\/td><td>No<\/td><td>No<\/td><td>Yes, if pinned<\/td><\/tr><tr><td>A provider fixes a known quirk<\/td><td>Sometimes<\/td><td>Yes, with noise<\/td><td>Yes, as a type flip<\/td><td>Sometimes<\/td><td>No<\/td><td>Yes, with the workaround named<\/td><\/tr><tr><td>A list shrinks because objects went inactive<\/td><td>No<\/td><td>Yes<\/td><td>No<\/td><td>No<\/td><td>No<\/td><td>No, needs a data snapshot<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Read the last row carefully. A drift harness watches shapes. It does not replace the daily data snapshots that production code needs for anything the provider only lists while it is active.<\/p>\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<p class=\"wp-block-paragraph\"><strong>Probe list and safety<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Allowlist exact path and method pairs in code, not configuration.<\/li>\n\n\n\n<li>Exclude every send, create, update and delete endpoint, including method-keyed paths such as <code>WAApi\/template<\/code>.<\/li>\n\n\n\n<li>Send <code>userid<\/code> with the <code>apikey<\/code> header on every probe; add <code>output=json<\/code> everywhere.<\/li>\n\n\n\n<li>Disable automatic retries and redirect following in the probe client.<\/li>\n\n\n\n<li>Use a dedicated read-only key if your account supports several.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Capture<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Save raw body, HTTP status and headers for every probe, retained for at least thirty days.<\/li>\n\n\n\n<li>Scan raw text for unsafe integers before parsing; record the tokens found.<\/li>\n\n\n\n<li>Parse with exact integers (<code>JSON_BIGINT_AS_STRING<\/code>, Python <code>json<\/code>, a tokenizer pre-pass in JavaScript).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fingerprint and diff<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Reduce each body to path, JSON type and lexical class; collapse arrays to <code>[*]<\/code>.<\/li>\n\n\n\n<li>Keep one fingerprint per endpoint per branch, with the branch decided by <code>status<\/code> at the right depth.<\/li>\n\n\n\n<li>Mark empty arrays and treat their children as unobserved.<\/li>\n\n\n\n<li>Diff catalogues row by row: response codes keyed on <code>errorCode<\/code>, delivery codes keyed on <code>peId<\/code> plus <code>identifier<\/code>.<\/li>\n\n\n\n<li>Assert declared <code>count<\/code> equals parsed rows and unique keys equal parsed rows.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Triage<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Maintain <code>quirks.yaml<\/code> in the client repository; one entry per workaround.<\/li>\n\n\n\n<li>Classify every change as additive, narrowing, type flip or semantic, per branch.<\/li>\n\n\n\n<li>Route: log additive, gate on narrowing, page on type flips and catalogue status flips, ticket semantic changes.<\/li>\n\n\n\n<li>Route unknown catalogue codes to the permanent default in your error classifier.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Operation<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Nightly monitor job against production; gate job on client releases using stored fingerprints only.<\/li>\n\n\n\n<li>Alert on harness health, not just on findings.<\/li>\n\n\n\n<li>Review additive findings weekly and retire workarounds whose quirks have been gone for two weeks.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The behaviours below are not pinned down by anything you can read today. Each item gives the choice that stays safe whichever way the answer turns out, and none of them requires waiting for an answer before you build.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Fingerprint the error branch from observation, and treat every error sample you have not captured yourself as unknown.<\/strong> Several read endpoints publish a success sample only. Capture your own error body with a parameter probe, store it as the baseline, and let your client rely on nothing in the error branch except <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Key delivery codes on <code>peId<\/code> and <code>identifier<\/code> together.<\/strong> Which of the two is the stable identity of a delivery code is not stated. A composite key detects a change to either one, and a change to either one is worth a look.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Prefer parameter probes over bad-credential probes on your production login.<\/strong> How many failed authentications trigger a lockout or an alert is not something you can plan around. A range that exceeds the published maximum reaches the error envelope without spending any of that budget.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Treat a clamped range as a successful probe of the wrong branch, not as proof the limit is gone.<\/strong> If an over-long range returns success, record the observed behaviour for that probe, switch to a different error probe, and keep your own validator enforcing the published maximum.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Infer the year of an RCS <code>dailyTrend<\/code> label from the request range, and pin that inference in the ledger.<\/strong> Labels such as <code>\"Jul 08\"<\/code> carry no year. Resolve each label to the one date inside your <code>fromDate<\/code> to <code>toDate<\/code> window that matches it; with a maximum range of 31 days, at most one year can match. If ISO dates ever arrive, the lexical class changes and the ledger tells you to remove the inference.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Do not assign meanings to the integer <code>direction<\/code> and <code>msgType<\/code> values in RCS analytics breakdowns.<\/strong> Store them as observed integers, fingerprint them as an observed enum, and label them in your reporting from your own traffic, for example by comparing counts with the rows you sent and received that day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Assume catalogue order is meaningless and catalogue size can move either way.<\/strong> Sort by key before diffing and never infer anything from position. A removed code is a finding to review, not an instruction to delete its handler.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Treat the <code>description<\/code> and <code>cause<\/code> texts as display strings that may change.<\/strong> Never match on them. Key everything on codes and identifiers, and show the text to humans.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Pin the kind of every status-like field you log, even though you never branch on it.<\/strong> <code>code<\/code> is quoted on some families and unquoted on others. Whether that converges is not known; a pinned kind turns convergence into a visible, harmless change instead of a surprise in a log parser.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Run the harness at least daily and assume changes can ship at any hour.<\/strong> There is no published change calendar you can subscribe to. The interval between runs is the longest a change can go unnoticed, so choose it by how long you can tolerate a broken parser, not by cost.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Store raw bodies for every probe even when nothing changed.<\/strong> When a change is eventually detected, the question is always &#8220;since when&#8221;. A stored body per night answers it without guessing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Keep the probe allowlist smaller than the read surface you are allowed to call.<\/strong> Some reads, such as delivery reports, depend on traffic and fingerprint noisily. Add them only when you can fingerprint days that have rows, and leave them out rather than muting their alerts.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is contract testing for a messaging API?<\/strong><br>It is checking, on a schedule, that the responses your integration depends on still have the shape your parser expects. With a third-party provider you run both halves yourself: you record what your code reads, and you verify the live API against it using read-only calls.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use Pact against a third-party SMS or WhatsApp API?<\/strong><br>Pact&#8217;s consumer half works anywhere, and it is useful for testing your own client. The provider-verification half needs the provider to replay your contract before releasing, which a public messaging platform will not do. A nightly fingerprint harness fills that gap from your side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does contract testing a messaging API cost anything?<\/strong><br>Not if the harness only calls read endpoints. The code catalogues, profile, rate plan, sender ID and template reads, RCS bot list and analytics, WhatsApp analytics and Telegram template list are all reads. Send endpoints are excluded entirely, including with a test flag.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between a schema snapshot and a value snapshot?<\/strong><br>A value snapshot stores the actual response and fails whenever any value changes, such as a count moving from 198 to 199. A schema snapshot, or fingerprint, stores only paths, types and lexical classes, so it changes only when the shape changes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why fingerprint the error branch separately?<\/strong><br>Because the type of a payload key can depend on the branch. On RCS analytics, <code>analytics<\/code> is an object on success and <code>[]<\/code> on error. Merging the two branches would hide the rule that <code>status<\/code> decides the type.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I trigger an error response without breaking anything?<\/strong><br>Send a valid, authenticated read with an invalid parameter, such as an RCS analytics date range longer than 31 days or a WhatsApp analytics range longer than 365 days. Avoid bad-password probes on your production login.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I stop JavaScript from corrupting nineteen-digit message IDs?<\/strong><br>Scan the raw response text for unquoted integers larger than <code>Number.MAX_SAFE_INTEGER<\/code> before calling <code>JSON.parse<\/code>, and wrap them in quotes. Do it with a tokenizer that skips string contents, and make it idempotent so a provider fix does not double-quote anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does the response code list return?<\/strong><br><code>SMSApi\/info\/responsecodes<\/code> accepts POST and returns <code>response.responsecodesList<\/code>, where each row is wrapped in an <code>errorcode<\/code> object holding <code>errorCode<\/code>, <code>httpCode<\/code>, <code>status<\/code> and <code>description<\/code>. The published sample shows <code>count<\/code> as 198.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does the delivery code list return?<\/strong><br><code>SMSApi\/info\/deliverycodes<\/code> accepts POST and returns <code>response.deliverycodesList<\/code>, where each row is also wrapped in an <code>errorcode<\/code> object, holding <code>peId<\/code>, <code>identifier<\/code>, <code>status<\/code> and <code>cause<\/code>. The published sample shows <code>count<\/code> as 45.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What should happen when a new error code appears?<\/strong><br>Your classifier should already route unknown codes to a permanent, do-not-retry default. The harness then opens a ticket so someone can classify the new code properly as permanent, transient or ambiguous.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How often should the harness run?<\/strong><br>At least nightly. The run interval is the longest a provider change can go unnoticed, and a dozen read calls cost nothing, so hourly is reasonable if a broken parser would hurt within a day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is a quirk ledger?<\/strong><br>A file listing every provider deviation your client code handles deliberately, with the path, branch and expected kind. The harness checks each entry, silences alerts for known quirks and raises one when a quirk disappears, naming the workaround that depended on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should the harness run in my unit test suite?<\/strong><br>No. Unit tests with transport mocks run on every commit. The harness makes real calls and belongs on a schedule, with a separate release gate that reads stored fingerprints and needs no credentials.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does this replace delivery report reconciliation?<\/strong><br>No. The harness watches the shape of the API, not your traffic. Delivery truth still comes from delivery reports, ingested and reconciled as a system of record, as the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report ingestion guide<\/a> describes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want a second pair of eyes on a probe list or a quirk ledger before you switch it on, the SMSGatewayCenter engineering team is happy to review it with you. <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">Get in touch<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Build against an API you can verify every night.<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every endpoint in this guide is live on SMSGatewayCenter, including the free code catalogues and read-only analytics. Try the API in the sandbox, then point your harness at production reads.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link has-black-background-color has-background wp-element-button\" href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">Try the Sandbox<\/a><\/div>\n\n\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link has-vivid-cyan-blue-background-color has-background wp-element-button\" href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">Explore the developer API<\/a><\/div>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Latest posts<\/h3>\n\n\n<ul class=\"wp-block-latest-posts__list is-grid columns-3 wp-block-latest-posts is-layout-grid wp-container-core-latest-posts-is-layout-0fed8f92 wp-block-latest-posts-is-layout-grid\"><li><a class=\"wp-block-latest-posts__post-title\" href=\"https:\/\/www.smsgatewaycenter.com\/blog\/contract-testing-harness-messaging-api\/\">Contract Testing a Messaging API: A Nightly Drift Harness That Never Sends a Message<\/a><\/li>\n<li><a class=\"wp-block-latest-posts__post-title\" href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sender-identity-sms-whatsapp-rcs-telegram\/\">Sender Identity Across SMS, WhatsApp, RCS and Telegram: Sender IDs, WABA Numbers, Bots and Long Codes<\/a><\/li>\n<li><a class=\"wp-block-latest-posts__post-title\" href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-template-management-four-channels\/\">Message Template Management Across SMS, RCS, WhatsApp and Telegram: One API Comparison<\/a><\/li>\n<li><a class=\"wp-block-latest-posts__post-title\" href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">Reconciling a Messaging Invoice Across Four Channels: Credits, Currency and the Rate Plan API<\/a><\/li>\n<li><a class=\"wp-block-latest-posts__post-title\" href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">Receiving Messages on Four Channels: Inbox API Contracts Compared<\/a><\/li>\n<\/ul>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A provider you do not control will never run your Pact file. This guide builds a nightly harness that probes only free, read-only SMSGatewayCenter endpoints, fingerprints every response by path, JSON type and lexical class, provokes the error branch on purpose, diffs the two code catalogues row by row, and pins known quirks so that a quirk disappearing is caught as drift too. Python, Node.js, Java and PHP samples, each showing a different trap.<\/p>\n","protected":false},"author":118,"featured_media":3051,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2286,2289,2121,2017,2288,2232,2287,481,2240,632],"class_list":["post-3050","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-api-drift-detection","tag-ci","tag-contract-testing","tag-error-handling","tag-json-big-integers","tag-rcs-api","tag-schema-fingerprint","tag-sms-api","tag-telegram-api","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3050","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=3050"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3050\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3051"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3050"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3050"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3050"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}