
Table of Contents
- The Short Answer
- TL;DR
- Why Consumer-Driven Contracts Stall on a Third-Party Provider
- The Free Read-Only Surface You Can Probe Every Night
- Fingerprint the Shape, Ignore the Values
- Lexical Classes: The Part Generic Drift Tools Miss
- Probe the Error Branch on Purpose
- The Two Code Catalogues Are a Data Contract
- Classifying a Diff: Additive, Narrowing, Type Flip, Semantic
- The Quirk Ledger: Pin Known Deviations as Expected
- Harness Architecture
- Python: The Fingerprinter and the Differ
- Node.js: Catch Nineteen-Digit Numbers Before JSON.parse
- Java: A Catalogue Diff That Fails Closed
- PHP: An Allowlist That Makes Writes Impossible
- Storing Snapshots and Alerting Without Noise
- What Each Endpoint Teaches the Harness
- Wiring It Into CI and the Release Gate
- Decision Matrix: Which Check Catches Which Drift
- Implementation Checklist
- Unspecified Behaviour and How to Code Around It
- FAQs
The Short Answer
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 (SMSApi/info/responsecodes and SMSApi/info/deliverycodes), 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.
For each of them, the harness does four things. It calls the success branch and records a fingerprint: every JSON path with its type and a lexical class such as “string of digits” or “four-decimal string”, never the value. It provokes the error branch 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 [], or the key is dropped). It diffs the two code catalogues row by row, because a code flipping from success to error is a semantic change no shape check will see. And it checks every difference against a quirk ledger, a file of known deviations you already code around, so that a quirk which quietly disappears is flagged as drift too.
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.
TL;DR
- Consumer-driven contracts need the provider to run your pact. A third-party messaging API will not, so you verify its behaviour yourself, on a schedule, against the live read-only surface.
- Never let the harness reach a billable or mutating endpoint. Allowlist exact path and method pairs. Several endpoints select the operation by HTTP method, so a path allowlist alone is not enough.
- Fingerprint shapes, not values. Record path, JSON type and lexical class. A
countmoving from 198 to 199 is noise."200"becoming200is drift. - Lexical classes matter more than types.
"2707"and"Full Name"are both strings; one is an identifier."0.1700"is a string holding a fixed four-decimal amount."Jul 08"is a date label with no year. - Parse the raw text so long integers survive. Some rows carry unquoted nineteen-digit identifiers. JavaScript’s
JSON.parserounds them without an error. Flag them before parsing. - Probe the error branch deliberately. RCS analytics turns
analyticsinto[]on failure, the RCS bot list turnsbotsListinto[]with 403, and the rate plan omitsdata. Fingerprint success and error as two contracts. - Diff the code catalogues as data. Both lists wrap each row in a key named
errorcode. On the response list the inner key iserrorCodein camel case. Key the delivery list on the pairpeIdplusidentifier. - Pin known quirks. A ledger entry says “this path is expected to be an unquoted integer”. If it becomes quoted, that is also drift, because your coercion may now double-handle it.
- Route by severity. Additive changes log, narrowing changes fail the release gate, type flips page someone, catalogue changes open a ticket to classify the new code.
Why Consumer-Driven Contracts Stall on a Third-Party Provider
Consumer-driven contract testing, as popularised by Pact, 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.
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 four-layer testing guide for code that sends messages. That guide treats contract checks as one layer of your test pyramid: fixtures captured from real responses, plus a nightly catalogue diff.
This guide builds the missing half as a standalone system: a provider-verification harness that you own and run. The difference is not cosmetic.
| Question | Consumer-driven contract (Pact style) | Provider verification harness (this guide) |
|---|---|---|
| Who runs the check | The provider, in its pipeline | You, on a schedule, against production |
| What it compares | Recorded interactions against the provider build | Today’s live response shapes against yesterday’s |
| What it catches | A provider change that breaks a known consumer | Any shape change, including on paths you do not read yet |
| Cost per run | Zero for you | A dozen free read calls |
| When you find out | Before the provider ships | Within one schedule interval after it ships |
| What it needs from the provider | Participation | Nothing beyond the public API |
Two properties follow from that table. First, you find out after the change ships, so the harness must run often enough that “after” 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 sender identity comparison across four channels shows in detail.
The Free Read-Only Surface You Can Probe Every Night
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 https://unify.smsgateway.center/ and authenticate with userid plus the apikey header.
| Endpoint | Method | Payload key | Error branch in published sample |
|---|---|---|---|
SMSApi/info/responsecodes | POST only | response.responsecodesList | Not shown |
SMSApi/info/deliverycodes | POST only | response.deliverycodesList | Not shown |
SMSApi/account/readprofile | POST or GET | response.account | Not shown |
SMSApi/rateplan/read | See its page | response.data | data omitted |
SMSApi/senderid/read | POST or GET | response.senderidList | Not shown |
SMSApi/template/read | See its page | response.templateList | Not shown |
rest/rcs/v1/bots | GET | botsList | botsList becomes [], 403 |
rest/rcs/v1/analytics | GET | analytics | analytics becomes [] |
rest/wa/v1/analytics | GET only | analyticsList | analyticsList becomes [] |
rest/tg/v1/templates | GET only | list payload | Payload key becomes [] |
Three details in that table decide how the harness is built.
The method sometimes selects the operation. The WhatsApp template endpoint WAApi/template 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 SMSApi/senderid/create 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.
Authentication rules differ by family. On the rest/ families, userid is required even when the apikey header is present, and output=json is required. On the SMSApi/ family, the published tables describe apiKey as an alternative to userid and password. Send userid with the apikey header everywhere; it satisfies both families and keeps passwords out of your harness configuration.
Some endpoints carry date limits that make a free error probe. 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.
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 SMSApi/reports/status with method=getDlr and WAApi/report 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.
Fingerprint the Shape, Ignore the Values
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.
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 SMSApi/account/readprofile:
{
"response": {
"api": "account",
"action": "readprofile",
"status": "success",
"msg": "success",
"code": "200",
"count": 7,
"account": {
"fullName": "Full Name",
"postalAddress": "Janakput",
"postalCity": "2707",
"postalCountry": "101",
"postalRegion": "22",
"profilePic": "",
"enableCMS": "1"
}
}
}
Its fingerprint is a sorted map from path to kinds:
$ object
$.response object
$.response.account object
$.response.account.enableCMS str:digits
$.response.account.fullName str
$.response.account.postalAddress str
$.response.account.postalCity str:digits
$.response.account.postalCountry str:digits
$.response.account.postalRegion str:digits
$.response.account.profilePic str:empty
$.response.action str
$.response.api str
$.response.code str:digits
$.response.count int
$.response.msg str
$.response.status str
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.
Four rules keep a fingerprint honest.
Arrays collapse to [*]. 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.
An empty array is its own kind. 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 array:empty and treat its missing children as unobserved, not removed.
Keys are case-sensitive. The response code list wraps every row in a key named errorcode and names the inner field errorCode. A fingerprint that lower-cases keys would merge them and miss a rename.
Record kinds as sets. 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 “this field is a union”.
Lexical Classes: The Part Generic Drift Tools Miss
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. "2707" in postalCity is an identifier, not a city name. "1" in enableCMS is a flag. "Pending" in isEnabled on the sender ID list is a status, not a boolean. A change from "1" to "true" in a flag is invisible to a type check and fatal to == "1".
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.
| Class | Rule | Example from a published sample | Why it matters |
|---|---|---|---|
str:empty | Empty string | profilePic: "" | Empty and absent are different; code that checks presence breaks |
str:digits | Digits only | code: "200", peId: "0", postalCity: "2707" | Quoted numbers; a flip to unquoted breaks strict comparisons |
str:decimalN | Digits, a point, exactly N decimals | rate and dltRate on the rate plan, four decimals | Money as text; a change of N changes rounding |
str:isodate | Starts YYYY-MM-DD | fromDate: "2026-07-11" | Sortable, parseable |
str:labeldate | Three-letter month, space, two digits | date: "Jul 08" in RCS dailyTrend | Not parseable without a year; breaks across new year |
str:enum | Value in a small observed set for this path | status: "success", status: "FAILED" | New members are semantic drift |
str | Anything else | description, msg | Free text; never branch on it |
int | Integer within the safe range | count: 198 | Safe in every language |
int:unsafe | Integer beyond 2^53 minus 1 | Nineteen-digit uuId on WhatsApp report rows | Silently rounded by double-precision parsers |
number:decimal | Number with a fraction | Two-decimal charges on Telegram rows | Floating point; compare with tolerance |
bool, null | As JSON |
Two of these deserve a closer look.
int:unsafe. RFC 8259 says integers in the range from minus (2^53 minus 1) to 2^53 minus 1 are interoperable, because every implementation built on IEEE 754 double precision agrees on them. Larger integers are legal JSON, but a parser may round them. JavaScript’s JSON.parse 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 uuId, mobileNo and wabaNumber, 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.
str:enum. Some string paths carry a closed vocabulary: status on envelopes, status and identifier on delivery code rows, isEnabled 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 sender identity guide uses the same fail-closed allowlist for isEnabled in production code; the harness is where that allowlist gets its early warning.
Probe the Error Branch on Purpose
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:
| Behaviour | Where it appears | What a naive typed client does |
|---|---|---|
The payload key keeps its name but becomes [] | analytics on RCS analytics, botsList on the RCS bot list, analyticsList on WhatsApp analytics, the payload key on every Telegram endpoint | Binds [] to an object type and throws, or treats “empty list” as “no bots” and deactivates routing |
| The payload key is omitted | data on SMSApi/rateplan/read | Null dereference on a path that always existed in testing |
| Only the status fields change | Envelopes whose error sample carries no payload at all | Works, until one of the other two behaviours arrives |
Here is the RCS analytics pair, success and error, from its published samples:
{
"status": "success",
"analytics": {
"todayMessages": {"total": 40, "success": 38, "read": 20, "failed": 1, "pending": 0, "notSent": 1, "others": 0},
"dailyTrend": [
{"date": "Jul 08", "total": 30, "success": 28, "read": 18, "failed": 1, "notSent": 1}
]
},
"fromDate": "2026-07-11",
"toDate": "2026-07-17",
"statusCode": "200",
"reason": "success"
}
{
"status": "error",
"analytics": [],
"statusCode": "403",
"reason": "RCS API access is not enabled for this account."
}
The same key, analytics, is an object on one branch and an empty array on the other. A fingerprint that merged the two branches would record $.analytics as object | array:empty and call it a union, which hides the rule that actually matters: the type is decided by status. So the harness keeps two fingerprints per endpoint, one per branch, and a response is filed under the branch its top-level status says it is on.
That is also the client rule the harness is protecting. Parse loosely, read only the top-level status 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 or statusCode, and never parse msg, reason or description. The harness makes sure that rule keeps working by proving, every night, that status is still where you read it from.
How to provoke an error for free
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.
| Endpoint | Free error probe | Why it is safe |
|---|---|---|
rest/rcs/v1/analytics | fromDate and toDate forty days apart | The published maximum range is 31 days |
rest/wa/v1/analytics | A range of more than 365 days | The published maximum range is 365 days |
rest/rcs/v1/analytics | action set to a value outside overview, today, messagebreakdown, trend | A read with no side effect whatever the outcome |
Any rest/ read | Omit output=json | output is required on these families |
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.
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 demo environment, once a week.
The Two Code Catalogues Are a Data Contract
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.
The API response error code list at SMSApi/info/responsecodes accepts POST only and returns:
{
"response": {
"api": "info",
"action": "responsecodes",
"status": "success",
"msg": "success",
"code": "200",
"count": 198,
"responsecodesList": [
{
"errorcode": {
"errorCode": "200",
"httpCode": "200",
"status": "success",
"description": "SenderId created successfully."
}
},
{
"errorcode": {
"errorCode": "201",
"httpCode": "200",
"status": "error",
"description": "Given SenderId is already exists."
}
}
]
}
}
The delivery error code list at SMSApi/info/deliverycodes also accepts POST only and returns:
{
"response": {
"api": "info",
"action": "deliverycodes",
"status": "success",
"msg": "success",
"code": "200",
"count": 45,
"deliverycodesList": [
{
"errorcode": {
"peId": "0",
"identifier": "OTHER",
"status": "FAILED",
"cause": "Other"
}
},
{
"errorcode": {
"peId": "1",
"identifier": "SUCCESS",
"status": "DELIVERED",
"cause": "Delivered"
}
}
]
}
}
Diffing the two samples against each other gives the reader’s checklist:
| Property | Response code list | Delivery code list | Consequence |
|---|---|---|---|
| Row path | response.responsecodesList[i].errorcode | response.deliverycodesList[i].errorcode | Both wrap the row; a flat reader gets an object where it expected a string |
| Wrapper key | errorcode, lower case | errorcode, lower case, on delivery rows too | The wrapper name does not follow the list name |
| Natural key | errorCode, camel case, quoted digits | peId quoted digits, plus identifier text | Key the delivery list on the pair |
| Status vocabulary | success, error | DELIVERED, FAILED in the samples | Different case, different meaning |
count | Integer, 198 in the sample | Integer, 45 in the sample | Row count; assert it equals the rows you parsed |
| HTTP code per row | httpCode, quoted | Absent | Do not expect it on delivery rows |
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.
What to diff, row by row
Load each catalogue into a map keyed by its natural key and compare it with yesterday’s map.
- Added code. 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 retry strategy guide.
- Removed code. Usually harmless. Keep its handler; old delivery rows and logs still carry it.
- Status flipped between
successanderror, or betweenDELIVEREDandFAILED. 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. httpCodechanged. Matters if your client reads the HTTP status before the body. Fail the release gate until reviewed.- Description or cause changed. Low severity for code, high value for support. Update any customer-facing text that quotes it.
- Declared
countdiffers from parsed rows. Either rows were dropped by your parser, or the list contains duplicate keys. Both need a look before you trust the diff.
The unknown-code default deserves a sentence of its own. A code your classifier has never seen must fall through to permanent, 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.
Classifying a Diff: Additive, Narrowing, Type Flip, Semantic
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.
| Kind | Examples | Breaks existing readers? | Default action |
|---|---|---|---|
| Additive | A new key on rows; a new optional object; a new lexical class added to a path that already allowed free text | No, if your deserialiser ignores unknown properties | Log it. Review weekly. Consider adopting the field. |
| Narrowing | 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 | Yes, for any reader of that path | Fail the release gate for code that reads the path |
| Type flip | "200" becomes 200; str:digits becomes int:unsafe; an object becomes [] on the success branch; str:decimal4 becomes str:decimal2 | Yes, silently in loosely typed languages, loudly in strict ones | Page the owner. Parsers are at risk tonight. |
| Semantic | A new member of an observed enum; a catalogue code’s status flips; a date label format changes | Sometimes, and usually without an exception | Open a ticket; if it touches retry or routing, page |
Three rules make the classification reliable.
Classify per branch. An object becoming [] on the error branch is the known behaviour on several endpoints. The same change on the success branch is a type flip.
Unobserved is not removed. 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.
Widening a lexical class is additive only if you do not branch on it. A flag path moving from str:digits to str may mean "1" became "yes". If your code compares that flag to "1", mark the path as “branch-critical” in the ledger, and treat any change to its class as semantic.
The Quirk Ledger: Pin Known Deviations as Expected
Every long-lived integration accumulates workarounds: coerce this code to a string, quote that identifier before parsing, treat this empty array as “no payload”. Each workaround encodes a belief about the provider. The quirk ledger writes those beliefs down in a form the harness can check.
# quirks.yaml, one entry per deviation your client code handles on purpose
- id: rateplan-code-unquoted
endpoint: SMSApi/rateplan/read
branch: success
path: $.response.code
expect: int
workaround: client coerces code to string before logging
if_changed: harmless if coercion is idempotent; remove workaround after two weeks stable
- id: rateplan-data-omitted-on-error
endpoint: SMSApi/rateplan/read
branch: error
path: $.response.data
expect: absent
workaround: client never reads data unless status is success
- id: responsecodes-wrapper
endpoint: SMSApi/info/responsecodes
branch: success
path: $.response.responsecodesList[*].errorcode.errorCode
expect: str:digits
workaround: reader unwraps errorcode, reads errorCode
if_changed: catalogue reader breaks; page
- id: deliverycodes-peid-quoted
endpoint: SMSApi/info/deliverycodes
branch: success
path: $.response.deliverycodesList[*].errorcode.peId
expect: str:digits
- id: rcs-analytics-error-empty-array
endpoint: rest/rcs/v1/analytics
branch: error
path: $.analytics
expect: array:empty
- id: rcs-analytics-label-date
endpoint: rest/rcs/v1/analytics
branch: success
path: $.analytics.dailyTrend[*].date
expect: str:labeldate
workaround: year inferred from fromDate and toDate
if_changed: if ISO dates arrive, drop the inference; do not run both
- id: rcs-bots-error-empty-array
endpoint: rest/rcs/v1/bots
branch: error
path: $.botsList
expect: array:empty
workaround: routing never deactivates bots on a non-success read
- id: profile-city-is-an-id
endpoint: SMSApi/account/readprofile
branch: success
path: $.response.account.postalCity
expect: str:digits
workaround: display layer resolves the id; never shows it raw
Each entry is an assertion the harness runs in addition to the fingerprint diff, and it cuts both ways.
If the quirk is still there, the ledger silences the alert. The error-branch [] on RCS analytics is expected, so the harness does not page anyone for it every night.
If the quirk disappears, the ledger raises one. This is the case generic drift tools cannot see. Suppose the rate plan starts returning code as "200". 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 "123" into ""123"" and break the parse outright. The Node.js sample below is written so that it cannot do that, but many hand-rolled versions can.
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.
Harness Architecture
The harness is three small stages with a store between them.
Stage one, probe. 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.
Stage two, fingerprint and diff. 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.
Stage three, triage and route. 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.
Two design choices keep it cheap to run.
One run, one process, no concurrency. A dozen sequential read calls take seconds. Concurrency adds nothing and makes the raw evidence harder to line up.
The harness owns no credentials of its own beyond read access. 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 OAuth guide covers the token lifetimes if you run the harness against customer accounts connected through OAuth.
Python: The Fingerprinter and the Differ
The trap this sample shows: treating an empty array as “every child path was removed”.
Python’s json 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.
# fingerprint.py
import hashlib
import json
import re
from decimal import Decimal
SAFE_MAX = 2**53 - 1
DIGITS = re.compile(r"^\d+$")
DECIMAL = re.compile(r"^-?\d+\.(\d+)$")
ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}")
LABEL_DATE = re.compile(r"^[A-Z][a-z]{2} \d{2}$")
def lexical(value):
if value is None:
return "null"
if isinstance(value, bool): # bool before int: True is an int in Python
return "bool"
if isinstance(value, int):
return "int" if abs(value) <= SAFE_MAX else "int:unsafe"
if isinstance(value, Decimal):
return "number:decimal"
if isinstance(value, str):
if value == "":
return "str:empty"
if DIGITS.match(value):
return "str:digits"
m = DECIMAL.match(value)
if m:
return f"str:decimal{len(m.group(1))}"
if ISO_DATE.match(value):
return "str:isodate"
if LABEL_DATE.match(value):
return "str:labeldate"
return "str"
return type(value).__name__
def walk(node, path, out, empty_arrays):
if isinstance(node, dict):
out.setdefault(path, set()).add("object")
for key, child in node.items():
walk(child, f"{path}.{key}", out, empty_arrays)
elif isinstance(node, list):
if not node:
out.setdefault(path, set()).add("array:empty")
empty_arrays.add(path)
else:
out.setdefault(path, set()).add("array")
for item in node:
walk(item, f"{path}[*]", out, empty_arrays)
else:
out.setdefault(path, set()).add(lexical(node))
def fingerprint(raw_text: str):
body = json.loads(raw_text, parse_float=Decimal)
out, empty_arrays = {}, set()
walk(body, "$", out, empty_arrays)
canon = {p: sorted(k) for p, k in sorted(out.items())}
digest = hashlib.sha256(json.dumps(canon, sort_keys=True).encode()).hexdigest()
return canon, digest, sorted(empty_arrays)
def branch_of(body: dict) -> str:
"""Read status at the right depth: nested for SMSApi, top level for rest/."""
status = body.get("response", {}).get("status") if "response" in body else body.get("status")
return "success" if status == "success" else "error"
def diff(old: dict, new: dict, new_empty_arrays: list):
"""Return (kind, path, old_kinds, new_kinds). Children of an empty array are unobserved."""
def unobserved(path):
return any(path.startswith(a + "[*]") for a in new_empty_arrays)
changes = []
for path in sorted(old.keys() - new.keys()):
if not unobserved(path):
changes.append(("removed", path, old[path], None))
for path in sorted(new.keys() - old.keys()):
changes.append(("added", path, None, new[path]))
for path in sorted(old.keys() & new.keys()):
if old[path] != new[path]:
changes.append(("kinds_changed", path, old[path], new[path]))
return changes
Run against the profile sample, fingerprint produces the table shown in the fingerprint section, with count as int, code and postalCity as str:digits and profilePic as str:empty. Run against the RCS analytics success sample, dailyTrend[*].date comes out as str:labeldate while fromDate comes out as str:isodate, which is exactly the distinction a year-inference workaround needs protected.
Two lines in that code are there because of real behaviour. The bool check comes before the int check because isinstance(True, int) is true in Python, and a flag would otherwise be fingerprinted as a number. And branch_of looks for response first because the SMSApi/ family nests its envelope while the rest/ families do not; reading status at the wrong depth files every nested response under “error”.
Node.js: Catch Nineteen-Digit Numbers Before JSON.parse
The trap this sample shows: JSON.parse rounds large integers without raising an error, so the evidence is gone before your code sees it.
// longnumbers.mjs
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
// Tokenizer-aware scan: skips string contents, so digits inside "msg" are ignored.
export function findUnsafeIntegers(raw) {
const hits = [];
let inString = false;
let escaped = false;
for (let i = 0; i < raw.length; i++) {
const c = raw[i];
if (inString) {
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inString = false;
continue;
}
if (c === '"') { inString = true; continue; }
if (c === "-" || (c >= "0" && c <= "9")) {
let j = i;
while (j < raw.length && /[-+0-9.eE]/.test(raw[j])) j++;
const token = raw.slice(i, j);
if (/^-?\d+$/.test(token)) {
const n = BigInt(token);
if (n > MAX_SAFE || n < -MAX_SAFE) hits.push({ offset: i, token });
}
i = j - 1;
}
}
return hits;
}
// Wrap unsafe integers in quotes. Idempotent: an already quoted value sits
// inside a string, so the scanner never sees it and never double-quotes it.
export function quoteUnsafeIntegers(raw) {
const hits = findUnsafeIntegers(raw);
let out = "";
let last = 0;
for (const h of hits) {
out += raw.slice(last, h.offset) + `"${h.token}"`;
last = h.offset + h.token.length;
}
return { text: out + raw.slice(last), unsafe: hits.map((h) => h.token) };
}
// probe-report.mjs: how the harness uses it
import { quoteUnsafeIntegers } from "./longnumbers.mjs";
export function parseForHarness(raw) {
const { text, unsafe } = quoteUnsafeIntegers(raw);
const body = JSON.parse(text);
// The fingerprint records these paths as int:unsafe, not str:digits,
// so a provider change from unquoted to quoted still shows up as a diff.
return { body, unsafeTokens: new Set(unsafe) };
}
Three properties matter more than the code.
It scans outside strings only. 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.
It is idempotent. 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.
It reports, it does not hide. The harness records the tokens it quoted, so the fingerprint can still say int:unsafe 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.
The same helper belongs in production code for any row that can carry a long identifier, such as WhatsApp report rows. The WhatsApp wire contract guide shows where those rows come from and which fields to store as text.
Java: A Catalogue Diff That Fails Closed
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.
import com.fasterxml.jackson.databind.JsonNode;
import java.util.*;
public final class CatalogueDiff {
public record Row(String key, String httpCode, String status, String text) {}
public enum Kind { ADDED, REMOVED, STATUS_FLIPPED, HTTP_CODE_CHANGED, TEXT_CHANGED }
public record Change(Kind kind, String key, Row before, Row after) {}
public static final class ShapeDrift extends RuntimeException {
public ShapeDrift(String m) { super(m); }
}
/** SMSApi/info/responsecodes: response.responsecodesList[i].errorcode.errorCode */
public static Map<String, Row> readResponseCodes(JsonNode root) {
JsonNode resp = root.path("response");
if (!"success".equals(resp.path("status").asText())) {
throw new ShapeDrift("responsecodes: status is not success");
}
Map<String, Row> out = new TreeMap<>();
int rows = 0;
for (JsonNode wrapper : resp.path("responsecodesList")) {
rows++;
JsonNode row = wrapper.path("errorcode"); // wrapper, lower case
if (row.isMissingNode()) throw new ShapeDrift("row without errorcode wrapper");
String code = row.path("errorCode").asText(null); // inner, camel case
if (code == null || code.isEmpty()) throw new ShapeDrift("errorcode.errorCode missing");
out.put(code, new Row(code, row.path("httpCode").asText(""),
row.path("status").asText(""), row.path("description").asText("")));
}
checkCount(resp, rows, out.size(), "responsecodes");
return out;
}
/** SMSApi/info/deliverycodes: key on peId plus identifier, not on either alone. */
public static Map<String, Row> readDeliveryCodes(JsonNode root) {
JsonNode resp = root.path("response");
if (!"success".equals(resp.path("status").asText())) {
throw new ShapeDrift("deliverycodes: status is not success");
}
Map<String, Row> out = new TreeMap<>();
int rows = 0;
for (JsonNode wrapper : resp.path("deliverycodesList")) {
rows++;
JsonNode row = wrapper.path("errorcode");
String key = row.path("peId").asText("") + "|" + row.path("identifier").asText("");
out.put(key, new Row(key, "", row.path("status").asText(""), row.path("cause").asText("")));
}
checkCount(resp, rows, out.size(), "deliverycodes");
return out;
}
private static void checkCount(JsonNode resp, int rows, int unique, String name) {
int declared = resp.path("count").asInt(-1); // integer in both samples
if (declared != rows) throw new ShapeDrift(name + ": count " + declared + " but " + rows + " rows");
if (unique != rows) throw new ShapeDrift(name + ": duplicate keys, " + (rows - unique) + " collapsed");
}
public static List<Change> diff(Map<String, Row> before, Map<String, Row> after) {
List<Change> changes = new ArrayList<>();
Set<String> keys = new TreeSet<>(before.keySet());
keys.addAll(after.keySet());
for (String k : keys) {
Row b = before.get(k), a = after.get(k);
if (b == null) changes.add(new Change(Kind.ADDED, k, null, a));
else if (a == null) changes.add(new Change(Kind.REMOVED, k, b, null));
else {
if (!b.status().equalsIgnoreCase(a.status())) changes.add(new Change(Kind.STATUS_FLIPPED, k, b, a));
if (!b.httpCode().equals(a.httpCode())) changes.add(new Change(Kind.HTTP_CODE_CHANGED, k, b, a));
if (!b.text().equals(a.text())) changes.add(new Change(Kind.TEXT_CHANGED, k, b, a));
}
}
return changes;
}
}
Three choices in that code are the point.
path() rather than get(). Jackson’s path returns a missing node instead of null, so the reader can say exactly which level of the wrapper disappeared.
Two separate count checks. count against rows tells you the list is complete. Unique keys against rows tells you the TreeMap 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.
The diff fails closed. Any ShapeDrift 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’s default, and that default must be the permanent, do-not-retry path.
The Java and Spring Boot integration guide covers configuring the production deserialiser to ignore unknown properties, which is what makes additive changes harmless in the first place.
PHP: An Allowlist That Makes Writes Impossible
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.
<?php
declare(strict_types=1);
final class ReadOnlyProbe
{
private const BASE = 'https://unify.smsgateway.center/';
/** Exact path => allowed methods. Anything else is refused before any I/O. */
private const ALLOW = [
'SMSApi/info/responsecodes' => ['POST'],
'SMSApi/info/deliverycodes' => ['POST'],
'SMSApi/account/readprofile' => ['POST', 'GET'],
'SMSApi/senderid/read' => ['POST', 'GET'],
'rest/rcs/v1/bots' => ['GET'],
'rest/rcs/v1/analytics' => ['GET'],
'rest/wa/v1/analytics' => ['GET'],
'rest/tg/v1/templates' => ['GET'],
];
public function __construct(private string $userid, private string $apikey) {}
/** @return array{http:int, raw:string, body:mixed} */
public function call(string $path, string $method, array $params): array
{
$method = strtoupper($method);
if (!isset(self::ALLOW[$path]) || !in_array($method, self::ALLOW[$path], true)) {
throw new LogicException("Refusing {$method} {$path}: not on the read-only allowlist");
}
$params = ['userid' => $this->userid, 'output' => 'json'] + $params;
$ch = curl_init();
$url = self::BASE . $path;
if ($method === 'GET') {
$url .= '?' . http_build_query($params);
} else {
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => false, // a redirect must not turn a GET into anything else
CURLOPT_HTTPHEADER => ['apikey: ' . $this->apikey, 'Accept: application/json'],
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException("Transport failure on {$path}; no retry by design");
}
// Keep long integers as strings instead of floats.
$body = json_decode($raw, true, 512, JSON_BIGINT_AS_STRING);
return ['http' => $http, 'raw' => $raw, 'body' => $body];
}
}
// Error-branch probe: a range longer than the published 31-day maximum.
$probe = new ReadOnlyProbe(getenv('SGC_USERID'), getenv('SGC_APIKEY'));
$r = $probe->call('rest/rcs/v1/analytics', 'GET', [
'action' => 'overview',
'fromDate' => date('Y-m-d', strtotime('-40 days')),
'toDate' => date('Y-m-d'),
]);
file_put_contents(sprintf('snapshots/raw/rcs-analytics-error-%s.json', date('Ymd')), $r['raw']);
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: WAApi/template, because the same path creates with POST and a slip in one probe definition would create a template in production; SMSApi/senderid/create, which also accepts GET; and every send endpoint. CURLOPT_FOLLOWLOCATION 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.
JSON_BIGINT_AS_STRING 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.
Storing Snapshots and Alerting Without Noise
The store is small and boring on purpose. Three tables cover it.
| Table | One row per | Columns |
|---|---|---|
probe_run | Harness run | run_id, started_at, finished_at, probes_ok, probes_failed, changes_additive, changes_narrowing, changes_type_flip, changes_semantic |
fingerprint | Endpoint, branch and run | run_id, endpoint, branch, digest, paths_json, raw_body_ref, http_status |
catalogue_row | Catalogue, key and run | run_id, catalogue, row_key, status, http_code, text |
Store the digest and the full path map. The digest makes “did anything change” a single comparison; the path map makes “what changed” answerable without reprocessing raw bodies. Keep raw bodies in object storage and reference them, because they are the evidence for every alert.
Alerting rules that keep the channel quiet enough to be read:
- Compare against the last run on the same branch that succeeded as a probe, not against the last run. A night where the network failed produces no fingerprint and must not produce a thousand “removed” findings the next morning.
- Alert on digest change, not on every run. A stable API should produce zero messages for weeks.
- Require two consecutive observations for narrowing on list endpoints. 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.
- Never alert on counts.
counton the catalogues,totalon analytics and the number of templates are values. The only count check that matters is declared count against parsed rows within the same response. - Put the probe’s own health on the dashboard. A harness that has silently failed for a week looks exactly like an API that has not changed for a week. Graph
probes_okand alert when it drops, the same way the observability guide for messaging pipelines treats any silent pipeline.
What Each Endpoint Teaches the Harness
Six endpoints give the harness most of its coverage. Each one teaches a different rule.
| Endpoint | Envelope | Status field | count means | Error payload | Quirk to pin |
|---|---|---|---|---|---|
SMSApi/info/responsecodes | Nested under response | code, quoted "200" | Row count | Not in the published sample | Wrapper errorcode, inner errorCode |
SMSApi/info/deliverycodes | Nested under response | code, quoted "200" | Row count | Not in the published sample | peId is a quoted string |
SMSApi/rateplan/read | Nested under response | code, unquoted 200 | Not present | data omitted | rate and dltRate are four-decimal strings |
SMSApi/account/readprofile | Nested under response | code, quoted "200" | Key count of account (7) | Not in the published sample | Identifiers in name-like fields (postalCity) |
rest/rcs/v1/analytics | Flat | statusCode, quoted "200" | Not present | analytics becomes [] | dailyTrend[*].date is a label with no year |
rest/rcs/v1/bots | Flat | Top-level status | Not present | botsList becomes [], 403 | Only active bots are returned |
The rows that follow from that table are rules you can put straight into code.
count has no single meaning. On the catalogues it is the number of rows. On the profile it is the number of keys in account. Elsewhere on the platform it is an object of total and current on delivery reports, and an object of total, pending, active and rejected on the sender ID list. The fingerprint records its kind per endpoint; your code should never read count through a shared helper.
code and statusCode are for logs, not for branching. 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 status only.
Rows with sibling objects are not uniform. RCS analytics todayMessages carries pending and others, while dailyTrend rows omit both. A shared row type for “a volume bucket” will either fail or fill zeros that were never sent. Fingerprint the two paths separately.
A read that lists “active” objects is not a registry. 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 RCS API reference and the sender identity guide both recommend.
The same entity changes spelling across endpoints. A WhatsApp number is wabaNumber on report rows, wabaNumber quoted on inbox rows and waNumber on analytics rows. Add the inbox and analytics reads to the probe list if you consume them, and pin each spelling separately; the four-channel inbox guide lists the inbox shapes.
Extend the list the same way for templates. SMSApi/template/read rows sit at response.templateList[i].template, while RCSApi/template/list returns a bare {"templates":[...]} with no status envelope at all, so its branch has to be inferred from the presence of templates rather than from status. The template management guide covers both shapes and the Telegram and WhatsApp equivalents.
Wiring It Into CI and the Release Gate
The harness has two jobs in a delivery pipeline, and they run on different triggers.
Nightly, against production, as a monitor. 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’s side.
On every release of your own client, as a gate. 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.
# .github/workflows/messaging-contract.yml
name: messaging-contract
on:
schedule:
- cron: "15 1 * * *" # nightly monitor
pull_request:
paths: ["messaging/**", "quirks.yaml"]
jobs:
monitor:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m harness.run --probes probes.yaml --ledger quirks.yaml --store "$STORE_URL"
env:
SGC_USERID: ${{ secrets.SGC_USERID }}
SGC_APIKEY: ${{ secrets.SGC_READONLY_APIKEY }}
STORE_URL: ${{ secrets.HARNESS_STORE_URL }}
gate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m harness.gate --reads messaging/reads.yaml --ledger quirks.yaml --store "$STORE_URL"
env:
STORE_URL: ${{ secrets.HARNESS_STORE_URL }}
The gate needs one extra artefact: reads.yaml, 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.
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 four-layer testing guide covers where mocks and fixtures fit, and the fixtures it recommends are the raw bodies this harness already stores.
Decision Matrix: Which Check Catches Which Drift
| Drift | Type-only schema check | Value snapshot | Shape fingerprint with lexical classes | Error-branch probe | Catalogue row diff | Quirk ledger |
|---|---|---|---|---|---|---|
"200" becomes 200 | No, if both map to “scalar” | Yes, plus endless noise | Yes | Only on that branch | No | Yes, if pinned |
| A nineteen-digit identifier arrives unquoted | No | No | Yes, as int:unsafe on the raw text | No | No | Yes, if pinned |
An object becomes [] on the error branch | Only if the error branch is probed | Yes, with noise | Yes, per branch | Yes | No | Yes, silences the known case |
| A key disappears from rows | Yes | Yes, with noise | Yes, if rows were observed | No | No | No |
| A new failure code appears | No | Yes, with noise | No | No | Yes | No |
| A code flips from success to error | No | Yes, with noise | No | No | Yes | No |
| A date label changes format | No | Yes, with noise | Yes, via lexical class | No | No | Yes, if pinned |
| A provider fixes a known quirk | Sometimes | Yes, with noise | Yes, as a type flip | Sometimes | No | Yes, with the workaround named |
| A list shrinks because objects went inactive | No | Yes | No | No | No | No, needs a data snapshot |
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.
Implementation Checklist
Probe list and safety
- Allowlist exact path and method pairs in code, not configuration.
- Exclude every send, create, update and delete endpoint, including method-keyed paths such as
WAApi/template. - Send
useridwith theapikeyheader on every probe; addoutput=jsoneverywhere. - Disable automatic retries and redirect following in the probe client.
- Use a dedicated read-only key if your account supports several.
Capture
- Save raw body, HTTP status and headers for every probe, retained for at least thirty days.
- Scan raw text for unsafe integers before parsing; record the tokens found.
- Parse with exact integers (
JSON_BIGINT_AS_STRING, Pythonjson, a tokenizer pre-pass in JavaScript).
Fingerprint and diff
- Reduce each body to path, JSON type and lexical class; collapse arrays to
[*]. - Keep one fingerprint per endpoint per branch, with the branch decided by
statusat the right depth. - Mark empty arrays and treat their children as unobserved.
- Diff catalogues row by row: response codes keyed on
errorCode, delivery codes keyed onpeIdplusidentifier. - Assert declared
countequals parsed rows and unique keys equal parsed rows.
Triage
- Maintain
quirks.yamlin the client repository; one entry per workaround. - Classify every change as additive, narrowing, type flip or semantic, per branch.
- Route: log additive, gate on narrowing, page on type flips and catalogue status flips, ticket semantic changes.
- Route unknown catalogue codes to the permanent default in your error classifier.
Operation
- Nightly monitor job against production; gate job on client releases using stored fingerprints only.
- Alert on harness health, not just on findings.
- Review additive findings weekly and retire workarounds whose quirks have been gone for two weeks.
Unspecified Behaviour and How to Code Around It
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.
One. Fingerprint the error branch from observation, and treat every error sample you have not captured yourself as unknown. 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 status.
Two. Key delivery codes on peId and identifier together. 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.
Three. Prefer parameter probes over bad-credential probes on your production login. 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.
Four. Treat a clamped range as a successful probe of the wrong branch, not as proof the limit is gone. 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.
Five. Infer the year of an RCS dailyTrend label from the request range, and pin that inference in the ledger. Labels such as "Jul 08" carry no year. Resolve each label to the one date inside your fromDate to toDate 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.
Six. Do not assign meanings to the integer direction and msgType values in RCS analytics breakdowns. 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.
Seven. Assume catalogue order is meaningless and catalogue size can move either way. 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.
Eight. Treat the description and cause texts as display strings that may change. Never match on them. Key everything on codes and identifiers, and show the text to humans.
Nine. Pin the kind of every status-like field you log, even though you never branch on it. 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.
Ten. Run the harness at least daily and assume changes can ship at any hour. 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.
Eleven. Store raw bodies for every probe even when nothing changed. When a change is eventually detected, the question is always “since when”. A stored body per night answers it without guessing.
Twelve. Keep the probe allowlist smaller than the read surface you are allowed to call. 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.
FAQs
What is contract testing for a messaging API?
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.
Can I use Pact against a third-party SMS or WhatsApp API?
Pact’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.
Does contract testing a messaging API cost anything?
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.
What is the difference between a schema snapshot and a value snapshot?
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.
Why fingerprint the error branch separately?
Because the type of a payload key can depend on the branch. On RCS analytics, analytics is an object on success and [] on error. Merging the two branches would hide the rule that status decides the type.
How do I trigger an error response without breaking anything?
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.
How do I stop JavaScript from corrupting nineteen-digit message IDs?
Scan the raw response text for unquoted integers larger than Number.MAX_SAFE_INTEGER before calling JSON.parse, 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.
What does the response code list return?SMSApi/info/responsecodes accepts POST and returns response.responsecodesList, where each row is wrapped in an errorcode object holding errorCode, httpCode, status and description. The published sample shows count as 198.
What does the delivery code list return?SMSApi/info/deliverycodes accepts POST and returns response.deliverycodesList, where each row is also wrapped in an errorcode object, holding peId, identifier, status and cause. The published sample shows count as 45.
What should happen when a new error code appears?
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.
How often should the harness run?
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.
What is a quirk ledger?
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.
Should the harness run in my unit test suite?
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.
Does this replace delivery report reconciliation?
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 delivery report ingestion guide describes.
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. Get in touch.
Build against an API you can verify every night.
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.
Latest posts
- Contract Testing a Messaging API: A Nightly Drift Harness That Never Sends a Message
- Sender Identity Across SMS, WhatsApp, RCS and Telegram: Sender IDs, WABA Numbers, Bots and Long Codes
- Message Template Management Across SMS, RCS, WhatsApp and Telegram: One API Comparison
- Reconciling a Messaging Invoice Across Four Channels: Credits, Currency and the Rate Plan API
- Receiving Messages on Four Channels: Inbox API Contracts Compared