{"id":2706,"date":"2026-08-21T08:45:36","date_gmt":"2026-08-21T03:15:36","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2706"},"modified":"2026-08-21T08:45:39","modified_gmt":"2026-08-21T03:15:39","slug":"observability-for-messaging-pipelines","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/","title":{"rendered":"Observability for Messaging Pipelines: The Metrics That Actually Predict an Incident"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Most messaging dashboards measure the wrong clock. Billing fires at submission and delivery arrives minutes later, so your submission panel can be entirely green while delivery quietly collapses. This guide covers the four leading indicators that actually predict an incident, runnable Prometheus and OpenTelemetry instrumentation in Python and Node.js, how to build a real delivery funnel from the SMSGatewayCenter Delivery Report and Dashboard Analytics APIs, ratio-based alerting, per-message-class SLOs, and an incident review table mapping each symptom to the metric that would have caught it hours earlier.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/observability-messaging-pipelines-metrics.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/observability-messaging-pipelines-metrics-1024x584.webp\" alt=\"Technical illustration of a messaging pipeline observability funnel showing submission, acceptance, delivery and reconciliation stages with divergent metric curves\" class=\"wp-image-2707\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/observability-messaging-pipelines-metrics-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/observability-messaging-pipelines-metrics-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/observability-messaging-pipelines-metrics-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/observability-messaging-pipelines-metrics.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Submission and delivery are two different clocks. Instrumenting only the first is how messaging incidents stay invisible for hours.<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Table of Contents<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#executive-summary\" data-type=\"internal\" data-id=\"#executive-summary\">Executive Summary<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\" data-type=\"internal\" data-id=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#why-messaging-observability\" data-type=\"internal\" data-id=\"#why-messaging-observability\">Why Messaging Observability Is Not Generic API Observability<\/a><\/li>\n\n\n\n<li><a href=\"#two-clocks-submission-metrics\" data-type=\"internal\" data-id=\"#two-clocks-submission-metrics\">Two Clocks: Submission Metrics Versus Delivery Metrics<\/a><\/li>\n\n\n\n<li><a href=\"#the-denominator-bug\" data-type=\"internal\" data-id=\"#the-denominator-bug\">The Denominator Bug That Makes Every Delivery Dashboard Lie<\/a><\/li>\n\n\n\n<li><a href=\"#queue-depth\" data-type=\"internal\" data-id=\"#queue-depth\">Queue Depth Is Meaningless Without Drain Rate<\/a><\/li>\n\n\n\n<li><a href=\"#the-four-metrics\" data-type=\"internal\" data-id=\"#the-four-metrics\">The Four Metrics That Actually Predict an Incident<\/a><\/li>\n\n\n\n<li><a href=\"#a-metric-taxonomy-for-messaging\" data-type=\"internal\" data-id=\"#a-metric-taxonomy-for-messaging\">A Metric Taxonomy for Messaging<\/a><\/li>\n\n\n\n<li><a href=\"#instrumenting-the-send-path\" data-type=\"internal\" data-id=\"#instrumenting-the-send-path\">Instrumenting the Send Path<\/a><\/li>\n\n\n\n<li><a href=\"#building-the-delivery-funnel\" data-type=\"internal\" data-id=\"#building-the-delivery-funnel\">Building the Delivery Funnel From Real DLR Data<\/a><\/li>\n\n\n\n<li><a href=\"#the-dashboard-analytics-api\" data-type=\"internal\" data-id=\"#the-dashboard-analytics-api\">The Dashboard Analytics API as a Cross-Check<\/a><\/li>\n\n\n\n<li><a href=\"#error-codes-turning-failed\" data-type=\"internal\" data-id=\"#error-codes-turning-failed\">Error Codes: Turning &#8220;Failed&#8221; Into Something Actionable<\/a><\/li>\n\n\n\n<li><a href=\"#cardinality\" data-type=\"internal\" data-id=\"#cardinality\">Cardinality: The Label That Will Take Down Your Metrics Backend<\/a><\/li>\n\n\n\n<li><a href=\"#alerting-ratios-not-counts\" data-type=\"internal\" data-id=\"#alerting-ratios-not-counts\">Alerting on Ratios, Not Counts<\/a><\/li>\n\n\n\n<li><a href=\"#per-message-class-slos\" data-type=\"internal\" data-id=\"#per-message-class-slos\">Per-Message-Class SLOs<\/a><\/li>\n\n\n\n<li><a href=\"#the-reconciliation-backlog\" data-type=\"internal\" data-id=\"#the-reconciliation-backlog\">The Reconciliation Backlog Is a Deferred Billing Dispute<\/a><\/li>\n\n\n\n<li><a href=\"#structured-logging-redaction\" data-type=\"internal\" data-id=\"#structured-logging-redaction\">Structured Logging and Redaction<\/a><\/li>\n\n\n\n<li><a href=\"#inbound-observability\" data-type=\"internal\" data-id=\"#inbound-observability\">Inbound Observability: Watching Your Own Webhook Receiver<\/a><\/li>\n\n\n\n<li><a href=\"#dashboard-layout\" data-type=\"internal\" data-id=\"#dashboard-layout\">Dashboard Layout: Four Panels, In This Order<\/a><\/li>\n\n\n\n<li><a href=\"#incident-review\" data-type=\"internal\" data-id=\"#incident-review\">Incident Review: Symptom to the Metric That Would Have Caught It<\/a><\/li>\n\n\n\n<li><a href=\"#how-to-instrument-a-messaging-pipeline\" data-type=\"internal\" data-id=\"#how-to-instrument-a-messaging-pipeline\">How to Instrument a Messaging Pipeline in Ten Steps<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\" data-type=\"internal\" data-id=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#implementation-checklist\" data-type=\"internal\" data-id=\"#implementation-checklist\">Implementation Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\" data-type=\"internal\" data-id=\"#ten-mistakes\">Ten Mistakes That Keep Messaging Incidents Invisible<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\" data-type=\"internal\" data-id=\"#faqs\">FAQs<\/a><\/li>\n\n\n\n<li><a href=\"#what-this-article-deliberately-does-not-claim\" data-type=\"internal\" data-id=\"#what-this-article-deliberately-does-not-claim\">What This Article Deliberately Does Not Claim<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"executive-summary\" class=\"wp-block-heading\">Executive Summary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The metrics that predict a messaging incident are oldest item age, drain rate measured against arrival rate, ambiguous-outcome rate, and cost per delivered message. Queue depth, total messages sent, and a headline delivery percentage are all lagging indicators that turn red only after users have already been affected. The structural reason is that a messaging pipeline runs on two clocks. Submission is synchronous, fast, and the moment you are billed. Delivery is asynchronous, arrives seconds to hours later through a delivery receipt, and is the only thing the recipient experiences. Instrument only the first clock and you build a dashboard that stays green through an outage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide shows how to instrument both clocks against real SMSGatewayCenter endpoints. It covers a Prometheus and OpenTelemetry instrumentation layer for the send path in Python and Node.js, a delivery funnel built from the <code>reports_dlrList<\/code> payload returned by <code>SMSApi\/reports\/status<\/code>, a cross-check against the consolidated <code>SMSApi\/dashboard\/read<\/code> analytics endpoint, a nightly contract diff against the two live error code list endpoints, ratio-based alerting rules, a per-message-class SLO table where OTP is the inversion of every normal rule, and an incident review table that maps each observed symptom back to the metric that would have surfaced it hours earlier.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It also names three specific traps that are close to universal. The first is the denominator bug: computing delivery rate as delivered over the sum of delivered and failed, which silently excludes pending and therefore reports a healthy number during the exact failure mode where delivery receipts stop arriving. The second is treating the daily rows of a summary API as if they sum to the overall status breakdown when the two sections count different status sets. The third is labelling metrics with the recipient mobile number, which turns a metrics backend into an unbounded cardinality problem within one campaign.<\/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>Billing fires at submission, satisfaction happens at delivery.<\/strong> These are different clocks. A dashboard that measures only submission is measuring only your spend, not your service.<\/li>\n\n\n\n<li><strong>Oldest item age is the single best leading indicator.<\/strong> Queue depth of 50,000 during a planned campaign is normal. An oldest item age of nine minutes on a queue that normally drains in four seconds is an incident, regardless of depth.<\/li>\n\n\n\n<li><strong>Never compute delivery rate as <code>delivered \/ (delivered + failed)<\/code>.<\/strong> That formula removes pending from the denominator, so it reports near-perfect health precisely when delivery receipts stop arriving. Always divide by submitted.<\/li>\n\n\n\n<li><strong>Ambiguous outcomes are a first-class metric.<\/strong> A read timeout on a send means you do not know whether you were billed. The rate of these records, and the age of the oldest unreconciled one, belong on the main dashboard.<\/li>\n\n\n\n<li><strong>Track cost per delivered message, not cost per submitted message.<\/strong> The <code>cost<\/code> field in the delivery report payload plus the <code>status<\/code> field lets you compute the real figure. It is always worse than the rate card, and the gap is where retries and segment inflation hide.<\/li>\n\n\n\n<li><strong>Alert on ratio changes, not absolute counts.<\/strong> Absolute counts alarm on every campaign launch. Ratios do not.<\/li>\n\n\n\n<li><strong>Latency is a histogram, never an average.<\/strong> The p50 of your submit latency hides the read-timeout tail that generates every ambiguous record you will spend the incident reconciling.<\/li>\n\n\n\n<li><strong>Cardinality discipline is not optional.<\/strong> Label by sender ID, channel, message class, route and error code. Never by mobile number, transaction ID, or message body.<\/li>\n\n\n\n<li><strong>OTP inverts the SLO.<\/strong> For OTP, a late message is a failed message. For marketing, a late message is a cheap message. One dashboard cannot serve both.<\/li>\n\n\n\n<li><strong>An unbounded backlog of unknown-state messages is a deferred billing dispute.<\/strong> Measure it, alert on it, and drain it.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-messaging-observability\" class=\"wp-block-heading\">Why Messaging Observability Is Not Generic API Observability<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There is no shortage of good generic observability writing. The <a href=\"https:\/\/prometheus.io\/docs\/practices\/histograms\/\" target=\"_blank\" rel=\"noopener nofollow\">Prometheus documentation on histograms and summaries<\/a> and the <a href=\"https:\/\/opentelemetry.io\/docs\/\" target=\"_blank\" rel=\"noopener nofollow\">OpenTelemetry specification<\/a> will teach you instrumentation mechanics better than any vendor blog. What that material cannot tell you is which of your measurements matter, because that depends on properties of the system being measured. Messaging pipelines have five properties that generic request-response services do not, and each one changes what you should be graphing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Every request costs money at the moment of submission, not at the moment of success.<\/strong> In an ordinary API integration, a failed call is free. In messaging, a submitted message is billed on submission and the credit does not come back if the handset never receives it. The published billing terms make this explicit: the applicable per-SMS rate is deducted from your wallet while sending SMS, and credits are non-refundable once the SMS is successfully submitted to the operator. This single fact reorders your metric priorities. Submission volume is a spend metric. Delivery volume is a service metric. If you graph only one, you have chosen to monitor either your invoice or your users, and most teams accidentally choose the invoice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The outcome arrives out of band, minutes to hours later.<\/strong> The synchronous response to a send tells you the platform accepted the request. It does not tell you the message arrived. The real outcome comes back as a delivery receipt, either polled from <code>SMSApi\/reports\/status<\/code> or pushed to a webhook you registered. That means half of your outcome data is generated by a process that runs on a completely different schedule from the one that generated the request, and either half can fail independently. A submission path that is perfectly healthy while the DLR path is dead is a very common and very invisible failure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The unit you are billed in is not the unit you call in.<\/strong> One HTTP request can carry ten thousand recipients, and one message body can occupy three segments. Ten thousand recipients times three segments is thirty thousand billable units from a single request. Any metric expressed in requests per second is therefore not a cost metric and not a throughput metric in any sense the operator cares about. This point is developed at length in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">Bulk SMS Pricing in India: What Actually Drives Cost<\/a>, and it has a direct observability consequence: your counters need to increment by recipients times segments, not by one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Failure is asymmetric across message classes.<\/strong> A marketing message that arrives four hours late costs you almost nothing. An OTP that arrives four hours late costs you the login, a support ticket, and a second charge when the user hits resend. A shipment alert that arrives after the courier does is worse than useless. The same latency number is a non-event, an incident and an embarrassment depending on which lane it happened in, which means a single global latency panel is close to worthless.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Half the logic lives outside your process.<\/strong> DLT scrubbing, operator-level retransmission, DND filtering and carrier queuing all happen where you have no instrumentation and no logs. You will never observe them directly. You can only observe their shadows, which are the <code>cause<\/code> and <code>globalErrorCode<\/code> fields on the delivery report and the gap between submit time and delivery time. Designing your instrumentation around those shadows is the entire discipline.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-clocks-submission-metrics\" class=\"wp-block-heading\">Two Clocks: Submission Metrics Versus Delivery Metrics<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/submission-metrics-vs-delivery-metrics.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/submission-metrics-vs-delivery-metrics.webp\" alt=\"Diagram about Two Clocks: Submission Metrics Versus Delivery Metrics\" class=\"wp-image-2708\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/submission-metrics-vs-delivery-metrics.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/submission-metrics-vs-delivery-metrics-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/submission-metrics-vs-delivery-metrics-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Draw the two clocks explicitly before you write any instrumentation code, because almost every confusing messaging dashboard is confusing for the same reason: it mixes measurements from both clocks into one panel and then averages them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Clock one, the submission clock,<\/strong> starts when your application decides to send and stops when the HTTP call to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> returns. It is measured in tens or hundreds of milliseconds. It is fully inside your process, so you can instrument it completely. It is the clock on which you are billed. Every metric derived from it is available immediately, which is why teams build their first dashboard entirely from this clock and why that dashboard is misleading.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Clock two, the delivery clock,<\/strong> starts at the same moment but stops when the handset acknowledges, which is reported back to you as a delivery receipt carrying its own <code>submitTime<\/code> and <code>deliveryTime<\/code>. It is measured in seconds to hours. Nothing about it is inside your process. It arrives through polling or a webhook push, both of which can break silently.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The two clocks produce four combinations, and the diagnostic value is entirely in the off-diagonal cases:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Submission clock<\/th><th>Delivery clock<\/th><th>What it means<\/th><th>How most dashboards report it<\/th><\/tr><\/thead><tbody><tr><td>Healthy<\/td><td>Healthy<\/td><td>Normal operation<\/td><td>Green, correctly<\/td><\/tr><tr><td>Degraded<\/td><td>Healthy<\/td><td>Your own send path is slow, but messages that get through are fine. Usually your database, your queue, or your own connection pool<\/td><td>Amber, correctly<\/td><\/tr><tr><td>Healthy<\/td><td>Degraded<\/td><td>The dangerous one. You are submitting and being billed normally while messages are not arriving. Route problem, DLT scrubbing failure, operator issue, or your DLR pipeline itself is broken<\/td><td><strong>Green, incorrectly<\/strong>, often for hours<\/td><\/tr><tr><td>Degraded<\/td><td>Degraded<\/td><td>A broad outage. Usually obvious<\/td><td>Red, correctly<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The third row is where messaging incidents live. It is invisible to a submission-only dashboard by construction, not by accident. If your monitoring has one gap, it is almost certainly this one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second-order trap inside the delivery clock. The <code>submitTime<\/code> and <code>deliveryTime<\/code> fields on a delivery report are generated by the platform, not by your application. The interval between them is the operator-side latency, which is genuinely useful. But the interval that your user experiences starts when your application decided to send, which is earlier, and includes any time the message spent in your own queue. Track both. If your queue adds ninety seconds before submission, an excellent operator-side latency figure is cold comfort to a user waiting for an OTP.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-denominator-bug\" class=\"wp-block-heading\">The Denominator Bug That Makes Every Delivery Dashboard Lie<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This deserves its own section because it is the most common single defect in messaging dashboards, it survives code review, and it fails in exactly the direction that hides an outage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The natural way to compute a delivery rate is to take the messages you know succeeded and divide by the messages whose outcome you know:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># WRONG. Do not ship this.\ndelivery_rate = delivered \/ (delivered + failed)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This looks defensible. You are excluding messages whose fate is undetermined, which feels statistically honest. It is not, because of how the failure mode works.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Consider the failure where delivery receipts stop arriving. Your webhook receiver is down, or the DLR poller crashed, or the platform-side DLR flow is delayed. Messages continue to be submitted normally. They sit in your database as pending forever. The <code>delivered<\/code> count freezes. The <code>failed<\/code> count freezes. The ratio between two frozen numbers is a constant. Your dashboard shows a delivery rate that is exactly as healthy as it was an hour ago, and it will keep showing that number indefinitely while your entire delivery observability is dead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The correct denominator is submitted:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Correct. Pending drags the ratio down, which is the point.\ndelivery_rate = delivered \/ submitted\npending_rate  = pending  \/ submitted\nfailure_rate  = failed   \/ submitted\n# These three should sum to 1.0. If they do not, you have lost records.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now the same failure shows up immediately: <code>delivered \/ submitted<\/code> falls as new submissions accumulate with no receipts, and <code>pending \/ submitted<\/code> climbs toward one. The dashboard turns amber within a few minutes of the DLR path breaking.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add the invariant check as an actual assertion, not a comment. If <code>delivered + failed + pending + rejected<\/code> does not equal <code>submitted<\/code>, you have a bookkeeping bug or a lost record, and you want to know that before you use any of the derived numbers to make a decision.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a related nuance in how partial rejection is reported on submission. The documented success response carries an <code>invalidMobile<\/code> field alongside a top-level <code>status<\/code>, so a request can return <code>\"status\": \"success\"<\/code> while some recipients in the batch were rejected outright. If your submitted counter increments by the number of recipients you sent, but some of those recipients were enumerated in <code>invalidMobile<\/code> and never entered the pipeline, your denominator is inflated and your delivery rate will read permanently low by a small margin. Parse <code>invalidMobile<\/code>, count those recipients into a separate <code>rejected_at_submission<\/code> counter, and exclude them from the delivery denominator. This is the same normalisation discipline described in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">Message Idempotency at Scale<\/a>, and it pays off twice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"queue-depth\" class=\"wp-block-heading\">Queue Depth Is Meaningless Without Drain Rate<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Queue depth is the most graphed and least informative metric in messaging. It is popular because it is trivial to obtain and it produces a satisfying sawtooth. It is uninformative because the same number carries opposite meanings depending on context.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A queue depth of 200,000 five seconds after you launched a campaign to 200,000 recipients is perfect. A queue depth of 400 on a transactional queue that is normally empty is an incident. Depth alone cannot distinguish these, so any threshold you set on it will either miss the second case or alarm constantly on the first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Depth becomes meaningful only in combination with drain rate, and the combination has a name and a unit that people already understand:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>time_to_drain = queue_depth \/ drain_rate\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If depth is 200,000 and you are draining 800 messages per second, time to drain is 250 seconds and everything is fine. If depth is 400 and you are draining 0.2 messages per second, time to drain is 2,000 seconds and your transactional queue has a 33-minute lag. The second is the emergency. Depth ranked them the wrong way round.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Better still, skip the arithmetic and measure the thing directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Oldest item age<\/strong> is the age of the oldest unprocessed item currently in the queue. It is the metric that already encodes depth, drain rate, and stalls, and it has the property that its unit is the same unit your users care about, which is seconds of waiting. If the oldest item in your OTP queue is 45 seconds old, you do not need to know the depth or the drain rate to know you have a problem, because 45 seconds is longer than a user will wait before pressing resend.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This metric is standard elsewhere in infrastructure precisely because it works. Cloud queue services expose it directly as <a href=\"https:\/\/aws.amazon.com\/about-aws\/whats-new\/2016\/08\/new-amazon-cloudwatch-metric-for-amazon-sqs-monitors-the-age-of-the-oldest-message\/\">approximate age of the oldest message<\/a>, and pub-sub systems expose the equivalent as oldest unacknowledged message age. If your queue technology gives it to you, use it. If it does not, compute it yourself, which for a database-backed outbox is one cheap query.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Oldest item age, in seconds, per message class.\n-- Run this on a schedule and export the result as a gauge.\nSELECT\n    message_class,\n    COALESCE(\n        EXTRACT(EPOCH FROM (now() - MIN(created_at)))::int,\n        0\n    ) AS oldest_item_age_seconds,\n    COUNT(*) AS depth\nFROM outbound_message\nWHERE state = 'pending'\nGROUP BY message_class;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two implementation notes that matter. Return zero rather than null for an empty queue, because a null gauge in most metrics backends becomes a gap in the series and a gap in the series will not fire an alert. And group by message class rather than reporting one global figure, because a healthy marketing backlog will mask a stalled OTP queue in any aggregate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is one failure mode oldest item age catches that nothing else does: the silent stall. A worker that crashes loudly gets caught by process monitoring. A worker whose event loop is blocked, or that is holding a lock it will never release, or that is stuck in a socket read with no timeout, keeps its process alive and its health check green. Depth may not even rise much if arrival rate is low. The only symptom is that the oldest item stops getting younger. That specific pathology, a blocked event loop producing a no-crash stall, is covered in the queue-worker context in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js integration tutorial<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-four-metrics\" class=\"wp-block-heading\">The Four Metrics That Actually Predict an Incident<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-metrics-observability.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-metrics-observability.webp\" alt=\"Diagram about The Four Metrics That Actually Predict an Incident\" class=\"wp-image-2709\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-metrics-observability.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-metrics-observability-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-metrics-observability-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Out of the dozens of things you could measure, four have genuine leading-indicator value, meaning they move before users are affected rather than after. Everything else on your dashboard is either a lagging confirmation or context for interpreting these four.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. Oldest item age, per message class<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Covered above. It is first because it is the only one of the four whose unit is directly comparable to user patience. Alert thresholds should be derived from message class, not from a global default: a value that is unremarkable for a marketing queue is a page for an OTP queue.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Drain rate versus arrival rate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two counters, graphed on the same axis, per class. Arrival rate is how fast work is entering the outbox. Drain rate is how fast it is leaving. The metric is not either line, it is the sign of the difference.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When arrival exceeds drain, the backlog is growing, and the important question is whether that is a transient burst or a sustained inversion. A campaign launch produces a spike where arrival massively exceeds drain for a short window and then drain catches up. That is normal and should not alarm. A sustained inversion, where arrival has exceeded drain for longer than your normal burst window, is the shape that reliably precedes an incident, because it means the backlog will grow without bound until something changes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The alerting rule that works is not &#8220;arrival exceeds drain&#8221;, it is &#8220;arrival has exceeded drain continuously for longer than N minutes&#8221;, where N is comfortably above your longest normal campaign spike. This is the same class of reasoning as burn rate alerting in SLO practice, and for the same reason: you want to alarm on the trajectory, not on the instantaneous value.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Drain rate has a second, counter-intuitive use. A drain rate that suddenly <em>increases<\/em> while latency <em>drops<\/em> is often not good news. By Little&#8217;s Law, throughput equals concurrency divided by latency, so if your submit latency falls off a cliff your throughput rises to match, and the most common reason for submit latency to fall off a cliff is that the platform started rejecting requests quickly instead of processing them slowly. Fast responses are not automatically good responses. Pair every drain rate panel with a success ratio panel so a rejection storm cannot masquerade as a performance improvement. This dynamic is developed further in the client-side rate limiting material referenced later.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Ambiguous-outcome rate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">An ambiguous outcome is any send attempt where you do not know whether the platform accepted the message. In practice this is almost always a read timeout: the connection was established, the request was written, and no response came back before your timeout fired. The request may have been fully processed and billed. It may not have been. From inside your process the two are indistinguishable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This matters more in messaging than anywhere else because the resolution of the ambiguity has a price. Retry and you may pay twice and deliver twice. Do not retry and you may deliver nothing. There is no free option, which is why the correct handling is to route these to an explicit <code>unknown<\/code> state and reconcile them against the delivery report rather than guessing. That state machine is the subject of <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">Message Idempotency at Scale<\/a>, and the central rule there is that an ambiguous outcome moves to <code>unknown<\/code> and never returns to <code>pending<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The observability consequence is that you need two metrics, not one. The <strong>rate<\/strong> of new ambiguous outcomes tells you the platform or the network is degrading right now. The <strong>age of the oldest unreconciled ambiguous record<\/strong> tells you whether your reconciliation process is keeping up. The first is an incident signal. The second is a billing exposure signal, and it is the one everyone forgets to build.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Critically, the ambiguous-outcome rate is a leading indicator for the submission path in a way that a plain error rate is not. Errors are definite: a <code>400<\/code> is a clean rejection you can classify and act on. Timeouts are the symptom of a system under strain that has not yet started returning errors. The ambiguous rate typically rises before the error rate does.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. Cost per delivered message<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not cost per submitted message. The rate card gives you the second for free. The first requires joining your cost data to your delivery data, and the gap between them is where every expensive mistake hides.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>effective_cost_per_delivered = sum(cost of all submitted) \/ count(delivered)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If your rate card says 0.14 per SMS and your effective cost per delivered message is 0.19, then roughly a quarter of your spend is producing nothing. That gap comes from a small number of causes, and the ratio tells you which one is worth investigating:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Messages billed as multiple segments because a body crossed the 160-character GSM-7 boundary, frequently because a curly apostrophe or an en dash was silently substituted into the text and forced the whole message into Unicode at 70 characters per segment.<\/li>\n\n\n\n<li>Retries that submitted the same message more than once.<\/li>\n\n\n\n<li>Recipients who were scrubbed, filtered or unreachable, billed at submission and never delivered.<\/li>\n\n\n\n<li>Duplicate sends from an idempotency gap.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>cost<\/code> field is present on each row of the <code>reports_dlrList<\/code> array returned by the delivery report endpoint, alongside <code>status<\/code>. That is everything you need to compute the real figure without any estimation. Graph it as a slowly moving line and alert on a percentage change rather than an absolute value, because the absolute value legitimately varies with your traffic mix.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"a-metric-taxonomy-for-messaging\" class=\"wp-block-heading\">A Metric Taxonomy for Messaging<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before writing instrumentation, sort every proposed metric into one of five buckets. Metrics that do not fit a bucket usually do not belong on the dashboard.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Bucket<\/th><th>Clock<\/th><th>Examples<\/th><th>Primary use<\/th><th>Common misuse<\/th><\/tr><\/thead><tbody><tr><td><strong>Queue metrics<\/strong><\/td><td>Pre-submission<\/td><td>Oldest item age, depth, arrival rate, drain rate<\/td><td>Predicting an incident before submission is affected<\/td><td>Graphing depth alone and setting a static threshold on it<\/td><\/tr><tr><td><strong>Submission metrics<\/strong><\/td><td>Submission<\/td><td>Submit latency histogram, HTTP status distribution, ambiguous-outcome rate, recipients times segments counter<\/td><td>Detecting send path and platform degradation, tracking spend<\/td><td>Treating request count as a volume or cost metric<\/td><\/tr><tr><td><strong>Delivery metrics<\/strong><\/td><td>Delivery<\/td><td>Delivery rate over submitted, pending rate, cause and error code distribution, submit-to-delivery latency histogram<\/td><td>Detecting what users actually experience<\/td><td>Excluding pending from the denominator<\/td><\/tr><tr><td><strong>Reconciliation metrics<\/strong><\/td><td>Post-delivery<\/td><td>Unknown-state backlog size, oldest unreconciled record age, reconciliation sweep duration<\/td><td>Bounding billing exposure and data quality<\/td><td>Not measuring it at all<\/td><\/tr><tr><td><strong>Cost metrics<\/strong><\/td><td>Cross-clock<\/td><td>Cost per delivered message, segment inflation ratio, spend per message class<\/td><td>Catching expensive silent regressions<\/td><td>Using rate card numbers instead of the <code>cost<\/code> field on real records<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The taxonomy has one rule attached to it: <strong>never put a metric from the submission bucket and a metric from the delivery bucket on the same panel with the same time axis and no annotation.<\/strong> They are measured on different clocks, so a spike in one will appear to lead or lag a spike in the other by an interval that has no physical meaning, and people will draw causal conclusions from the offset. Either separate the panels or explicitly annotate the delivery panel as lagged.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"instrumenting-the-send-path\" class=\"wp-block-heading\">Instrumenting the Send Path<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every code sample below uses the real send endpoint, <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code>, with the documented parameter names. Full parameter reference is on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS API documentation page<\/a>, and the complete endpoint index is at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-messaging-api\/\">the messaging API hub<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: Prometheus instrumentation with the correct cost unit<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"\nInstrumented send path for SMSGatewayCenter.\n\nKey decisions, each of which is deliberate:\n  * Latency is a Histogram, never a Summary or a gauge of the mean.\n  * Buckets are chosen around the timeout boundary, not log-spaced by habit.\n  * The volume counter increments by recipients * segments, not by 1.\n  * Connect timeout and read timeout are separated because they mean\n    completely different things for billing.\n  * Labels are strictly bounded. No mobile numbers anywhere.\n\"\"\"\n\nimport time\nimport httpx\nfrom prometheus_client import Counter, Histogram, Gauge\n\n# ---------------------------------------------------------------------------\n# Metric definitions\n# ---------------------------------------------------------------------------\n\n# Buckets straddle the read timeout (10.0s here). The bucket immediately\n# below the timeout is the one that tells you how close to the cliff you are\n# running. Default Prometheus buckets top out at 10s and will not show you\n# this, which is why they are wrong for this use case.\nSUBMIT_LATENCY = Histogram(\n    \"messaging_submit_latency_seconds\",\n    \"Wall-clock latency of a single submission request.\",\n    &#91;\"channel\", \"message_class\"],\n    buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0, 6.0, 8.0, 10.0, 15.0, 30.0),\n)\n\n# THE IMPORTANT ONE. Incremented by recipients * segments, because that is\n# the unit you are billed in. A counter of requests is not a volume metric.\nSUBMITTED_UNITS = Counter(\n    \"messaging_submitted_units_total\",\n    \"Billable units submitted (recipients x segments).\",\n    &#91;\"channel\", \"message_class\", \"encoding\", \"senderid\"],\n)\n\nSUBMIT_OUTCOME = Counter(\n    \"messaging_submit_outcome_total\",\n    \"Outcome of each submission attempt.\",\n    # outcome is one of: accepted, rejected, ambiguous, connect_failed\n    &#91;\"channel\", \"message_class\", \"outcome\", \"status_code\"],\n)\n\n# Separate from the outcome counter because this is the metric that gets its\n# own alert and its own panel. Duplicating it is cheap and worth it.\nAMBIGUOUS_OUTCOMES = Counter(\n    \"messaging_ambiguous_outcomes_total\",\n    \"Sends whose acceptance is unknown (read timeout after write).\",\n    &#91;\"channel\", \"message_class\"],\n)\n\nREJECTED_AT_SUBMISSION = Counter(\n    \"messaging_rejected_at_submission_total\",\n    \"Recipients enumerated in invalidMobile and never entered the pipeline.\",\n    &#91;\"channel\", \"message_class\"],\n)\n\n# ---------------------------------------------------------------------------\n# The instrumented call\n# ---------------------------------------------------------------------------\n\nSEND_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/send\"\n\n\ndef submit(client: httpx.Client, *, recipients, body, senderid,\n           message_class, dlt_entity_id=None, dlt_template_id=None):\n    \"\"\"\n    Submit one request and record every metric that matters.\n\n    Returns a dict describing the outcome. Never raises for a transport\n    problem; the caller needs the classification, not an exception.\n    \"\"\"\n    segments, encoding = count_segments(body)   # see the GSM-7 counter below\n    units = len(recipients) * segments\n    labels = {\"channel\": \"sms\", \"message_class\": message_class}\n\n    payload = {\n        \"sendMethod\": \"quick\",\n        \"mobile\": \",\".join(recipients),\n        \"msg\": body,\n        \"senderid\": senderid,\n        \"msgType\": \"unicode\" if encoding == \"UNICODE\" else \"text\",\n        \"output\": \"json\",\n        # Within-request recipient deduplication. This is NOT a retry guard\n        # and has no cross-request time window. Default is true.\n        \"duplicatecheck\": \"true\",\n    }\n    if dlt_entity_id:\n        payload&#91;\"dltEntityId\"] = dlt_entity_id\n    if dlt_template_id:\n        payload&#91;\"dltTemplateId\"] = dlt_template_id\n\n    started = time.monotonic()\n    try:\n        response = client.post(SEND_URL, data=payload)\n\n    except httpx.ConnectTimeout:\n        # DEFINITE non-submission. The connection was never established,\n        # so nothing was written, so nothing was billed. Safe to retry.\n        SUBMIT_LATENCY.labels(**labels).observe(time.monotonic() - started)\n        SUBMIT_OUTCOME.labels(**labels, outcome=\"connect_failed\",\n                              status_code=\"none\").inc()\n        return {\"state\": \"not_submitted\", \"retryable\": True, \"units\": units}\n\n    except httpx.ReadTimeout:\n        # AMBIGUOUS. The request was written. It may have been processed and\n        # billed. Record the units as submitted, because the pessimistic\n        # assumption is the correct one for a spend metric.\n        SUBMIT_LATENCY.labels(**labels).observe(time.monotonic() - started)\n        SUBMIT_OUTCOME.labels(**labels, outcome=\"ambiguous\",\n                              status_code=\"none\").inc()\n        AMBIGUOUS_OUTCOMES.labels(**labels).inc()\n        SUBMITTED_UNITS.labels(**labels, encoding=encoding,\n                               senderid=senderid).inc(units)\n        return {\"state\": \"unknown\", \"retryable\": False, \"units\": units}\n\n    SUBMIT_LATENCY.labels(**labels).observe(time.monotonic() - started)\n    parsed = response.json()\n\n    # statusCode is a QUOTED string on SMSApi\/send and an unquoted number on\n    # WAApi\/send. Coerce, always.\n    status_code = str(parsed.get(\"statusCode\", \"\")).strip()\n    status = str(parsed.get(\"status\", \"\")).strip().lower()\n\n    if status == \"success\":\n        SUBMIT_OUTCOME.labels(**labels, outcome=\"accepted\",\n                              status_code=status_code).inc()\n        SUBMITTED_UNITS.labels(**labels, encoding=encoding,\n                               senderid=senderid).inc(units)\n\n        # A populated invalidMobile is a PARTIAL success. Those recipients\n        # never entered the pipeline and must not sit in your delivery\n        # denominator inflating your pending rate forever.\n        invalid = &#91;m for m in str(parsed.get(\"invalidMobile\", \"\")).split(\",\")\n                   if m.strip()]\n        if invalid:\n            REJECTED_AT_SUBMISSION.labels(**labels).inc(len(invalid))\n\n        return {\n            \"state\": \"submitted\",\n            \"transaction_id\": str(parsed.get(\"transactionId\", \"\")),\n            \"invalid\": invalid,\n            \"units\": units,\n        }\n\n    SUBMIT_OUTCOME.labels(**labels, outcome=\"rejected\",\n                          status_code=status_code).inc()\n    return {\"state\": \"rejected\", \"status_code\": status_code,\n            \"reason\": parsed.get(\"reason\"), \"units\": units}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three things in that listing are worth stating plainly because they are where most instrumentation goes wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The bucket boundaries are chosen, not defaulted.<\/strong> The default Prometheus histogram buckets are tuned for fast web requests and stop at ten seconds. If your read timeout is ten seconds, the default buckets give you no resolution at all in the region where the interesting behaviour happens. Put buckets immediately below and immediately above your timeout so you can see the distribution creeping toward the cliff before it goes over.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The counter increments by units, not by one.<\/strong> This is the difference between a metric that answers &#8220;how much did that cost&#8221; and a metric that answers nothing. If you take one idea from this section, take this one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Connect timeout and read timeout are caught separately.<\/strong> A <code>requests<\/code>-style <code>except Timeout<\/code> that catches both is discarding the single most important distinction in the whole system, because one means definitely not billed and the other means possibly billed. The Python-specific version of this trap is covered in detail in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python integration tutorial<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The segment counter the volume metric depends on<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The units counter above is only as good as <code>count_segments<\/code>. Getting it wrong understates your volume metric by exactly the amount that matters.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>GSM7_BASIC = set(\n    \"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\\\"#\u00a4%&amp;'()*+,-.\/0123456789:;&lt;=&gt;?\"\n    \"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\"\n)\n# These ten cost TWO septets each, because they are encoded as an escape\n# character followed by the character itself.\nGSM7_EXTENDED = set(\"^{}\\\\&#91;~]|\u20ac\")\n\n\ndef count_segments(text: str):\n    \"\"\"\n    Return (segment_count, encoding) where encoding is 'TEXT' or 'UNICODE'.\n\n    Iterate code points, not UTF-16 code units. In Python, iterating a str\n    already gives code points, which is why this is simpler here than the\n    equivalent in JavaScript, where split('') will tear an astral-plane\n    emoji in half and give you the wrong answer.\n    \"\"\"\n    septets = 0\n    for ch in text:\n        if ch in GSM7_BASIC:\n            septets += 1\n        elif ch in GSM7_EXTENDED:\n            septets += 2\n        else:\n            # One non-GSM-7 character forces the ENTIRE message to UCS-2.\n            # A single curly apostrophe does this. So does an en dash,\n            # an ellipsis character, and the rupee sign.\n            units = len(text.encode(\"utf-16-be\")) \/\/ 2\n            if units &lt;= 70:\n                return 1, \"UNICODE\"\n            return -(-units \/\/ 67), \"UNICODE\"   # ceiling division\n\n    if septets &lt;= 160:\n        return 1, \"TEXT\"\n    return -(-septets \/\/ 153), \"TEXT\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The autocorrect trap embedded in that comment is worth a dedicated alert. If a template that has always billed as one TEXT segment starts billing as three UNICODE segments, a single character was substituted somewhere upstream, almost always by a rich text editor turning a straight apostrophe into a curly one. Your cost per delivered message will jump by a factor of three and nothing else will look wrong. Alert on the encoding label of <code>messaging_submitted_units_total<\/code> shifting from TEXT to UNICODE for a sender ID that has historically been TEXT.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You do not have to trust your own counter in isolation, either. The platform exposes <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/msg<\/code>, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-sms-length-cost\/\">SMS length and cost page<\/a>, which returns <code>msgList<\/code> with <code>encoding<\/code>, <code>length<\/code>, <code>remaining<\/code> and <code>credit<\/code> for a given body. Call it in CI against your template corpus and assert that your local counter agrees with it. That converts a subtle billing bug into a failing test, which is the general strategy argued for at length in the testing material referenced in the internal links section. The underlying segment tables are published on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/text-messages-chart\/\">text messages chart page<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Node.js: OpenTelemetry instrumentation<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Node.js instrumentation using the OpenTelemetry metrics API.\n\/\/\n\/\/ Note the two Node-specific hazards this guards against:\n\/\/   1. fetch() in Node has NO default timeout. Without AbortSignal.timeout\n\/\/      a hung socket produces a request that never resolves, which means\n\/\/      no metric is ever recorded and the message is invisible forever.\n\/\/      An unrecorded send is worse than a failed send.\n\/\/   2. AbortError from a timeout is AMBIGUOUS. It is not an error you can\n\/\/      safely retry.\n\nimport { metrics } from '@opentelemetry\/api';\n\nconst meter = metrics.getMeter('messaging-pipeline', '1.0.0');\n\nconst submitLatency = meter.createHistogram('messaging.submit.latency', {\n  description: 'Latency of a single submission request',\n  unit: 's',\n  advice: {\n    explicitBucketBoundaries: &#91;0.05, 0.1, 0.25, 0.5, 1, 2, 4, 6, 8, 10, 15, 30],\n  },\n});\n\nconst submittedUnits = meter.createCounter('messaging.submitted.units', {\n  description: 'Billable units submitted (recipients x segments)',\n});\n\nconst submitOutcome = meter.createCounter('messaging.submit.outcome', {\n  description: 'Outcome classification of each submission attempt',\n});\n\nconst SEND_URL = 'https:\/\/unify.smsgateway.center\/SMSApi\/send';\n\nexport async function submit({ recipients, body, senderid, messageClass,\n                               dltEntityId, dltTemplateId, apiKey }) {\n  const { segments, encoding } = countSegments(body);\n  const units = recipients.length * segments;\n  const attrs = { channel: 'sms', 'message.class': messageClass, encoding, senderid };\n\n  const form = new URLSearchParams({\n    sendMethod: 'quick',\n    mobile: recipients.join(','),\n    msg: body,\n    senderid,\n    msgType: encoding === 'UNICODE' ? 'unicode' : 'text',\n    output: 'json',\n    duplicatecheck: 'true',\n  });\n  if (dltEntityId) form.set('dltEntityId', dltEntityId);\n  if (dltTemplateId) form.set('dltTemplateId', dltTemplateId);\n\n  const started = process.hrtime.bigint();\n  const elapsed = () =&gt; Number(process.hrtime.bigint() - started) \/ 1e9;\n\n  let response;\n  try {\n    response = await fetch(SEND_URL, {\n      method: 'POST',\n      headers: {\n        apikey: apiKey,\n        'content-type': 'application\/x-www-form-urlencoded',\n      },\n      body: form,\n      \/\/ Without this line, a hung socket produces a promise that never\n      \/\/ settles and a metric that is never recorded.\n      signal: AbortSignal.timeout(10_000),\n    });\n  } catch (err) {\n    submitLatency.record(elapsed(), attrs);\n\n    if (err.name === 'TimeoutError' || err.name === 'AbortError') {\n      \/\/ Ambiguous. Count the units as submitted; assume you were billed.\n      submitOutcome.add(1, { ...attrs, outcome: 'ambiguous' });\n      submittedUnits.add(units, attrs);\n      return { state: 'unknown', retryable: false, units };\n    }\n\n    \/\/ Connection-level failures (ENOTFOUND, ECONNREFUSED) are definite\n    \/\/ non-submissions and are safe to retry.\n    submitOutcome.add(1, { ...attrs, outcome: 'connect_failed' });\n    return { state: 'not_submitted', retryable: true, units };\n  }\n\n  submitLatency.record(elapsed(), attrs);\n  const parsed = await response.json();\n\n  \/\/ Coerce: quoted string here, unquoted number on the WhatsApp endpoint.\n  const statusCode = String(parsed.statusCode ?? '');\n  const ok = String(parsed.status ?? '').toLowerCase() === 'success';\n\n  submitOutcome.add(1, {\n    ...attrs,\n    outcome: ok ? 'accepted' : 'rejected',\n    'status.code': statusCode,\n  });\n  if (ok) submittedUnits.add(units, attrs);\n\n  return {\n    state: ok ? 'submitted' : 'rejected',\n    transactionId: parsed.transactionId ? String(parsed.transactionId) : null,\n    invalid: String(parsed.invalidMobile ?? '').split(',').filter(Boolean),\n    statusCode,\n    units,\n  };\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">cURL: verifying the shape before you parse it<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Before writing a parser, confirm the response shape against your own account. The relevant discipline is to build your instrumentation around what you observed, not what you assumed.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Note testMessage=true. Documented verbatim as:\n# \"Enable true to test your message and messages wont be delivered when\n#  enabled true.\"\n# It lets you exercise the submission path and inspect the exact response\n# shape without a message landing on a real handset.\ncurl --location 'https:\/\/unify.smsgateway.center\/SMSApi\/send' \\\n  --header 'apikey: YOUR_API_KEY' \\\n  --header 'Content-Type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'sendMethod=quick' \\\n  --data-urlencode 'mobile=919999999999,919999999998' \\\n  --data-urlencode 'msg=Instrumentation shape check' \\\n  --data-urlencode 'senderid=YOURID' \\\n  --data-urlencode 'msgType=text' \\\n  --data-urlencode 'output=json' \\\n  --data-urlencode 'testMessage=true'\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two cautions on that flag. The documentation states that messages will not be delivered when it is enabled. It says nothing about whether credits are consumed, whether a delivery receipt is generated, or whether the response body differs from a live send. Those three behaviours are not documented, so do not assume them. Determine them empirically on your own account: read your balance from the dashboard endpoint, send one message with the flag set, read the balance again, then poll the delivery report for that time window and see whether a record appears. Record the answer in your own runbook, because your instrumentation&#8217;s treatment of test traffic depends on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you would rather not write the first request by hand at all, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS Postman collection<\/a> gives you the exact request shape as a fixture.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"building-the-delivery-funnel\" class=\"wp-block-heading\">Building the Delivery Funnel From Real DLR Data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The delivery funnel is the panel that makes the second clock visible. It is built from <code>SMSApi\/reports\/status<\/code>, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">SMS Delivery Report API page<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The endpoint is <code>GET<\/code> or <code>POST<\/code> to <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, <code>fromdate<\/code>, <code>todate<\/code>, <code>pageLimit<\/code>, <code>output<\/code>, and optional <code>mobileNo<\/code>, <code>uuId<\/code> or <code>startCursor<\/code>. The response carries a <code>reports_dlrList<\/code> array whose rows contain the fields you need: <code>status<\/code>, <code>cause<\/code>, <code>globalErrorCode<\/code>, <code>cost<\/code>, <code>submitTime<\/code>, <code>deliveryTime<\/code>, <code>msgId<\/code>, <code>uuId<\/code>, <code>mobileNo<\/code>, <code>senderName<\/code>, <code>network<\/code>, <code>country<\/code>, <code>channel<\/code>, <code>msgType<\/code>, <code>length<\/code>, <code>dltTemplateId<\/code>, <code>amount<\/code>, <code>flashMsg<\/code>, <code>text<\/code> and <code>cursorId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two structural properties of this endpoint matter for instrumentation design.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is a <strong>read<\/strong> endpoint, which means transport-level retries against it are safe. This is the opposite of the send path, where an automatic retry layer will double-bill you. Configure your HTTP client differently for the two, and if you use a shared client, be certain the retry policy is not global. A urllib3 <code>Retry<\/code> with <code>allowed_methods<\/code> including POST, applied to a shared session, is a documented way to double-send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is <strong>cursor-paced<\/strong>, via <code>startCursor<\/code> and the <code>cursorId<\/code> on each row. That makes it consumer-paced rather than producer-paced, which means your poller cannot be overwhelmed by a volume spike. You pull the next page when you are ready. Use that property: do not try to hold a full day of records in memory, iterate.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Python: a funnel builder that computes the right denominators<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"\nPoll the delivery report and emit funnel metrics.\n\nDesign notes:\n  * Cursor-paced, so memory is bounded regardless of volume.\n  * cost is parsed with Decimal, never float. Money is not binary floating\n    point and a cost dashboard that drifts is a cost dashboard nobody trusts.\n  * The submit-to-delivery gap is recorded as a HISTOGRAM. The average of\n    this number is meaningless because the distribution is heavily bimodal:\n    most messages land in seconds, a tail lands in minutes, and the mean\n    sits in a valley where almost no real message lives.\n\"\"\"\n\nfrom decimal import Decimal\nfrom datetime import datetime\nfrom prometheus_client import Counter, Histogram\n\nDLR_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status\"\n\nDELIVERY_OUTCOME = Counter(\n    \"messaging_delivery_outcome_total\",\n    \"Terminal delivery outcomes, from the delivery report.\",\n    &#91;\"channel\", \"status\", \"cause\", \"network\", \"senderid\"],\n)\n\nDELIVERY_COST = Counter(\n    \"messaging_delivery_cost_total\",\n    \"Summed cost from delivery report rows, in account currency units.\",\n    &#91;\"channel\", \"status\", \"senderid\"],\n)\n\n# Buckets in SECONDS. Note how far right they run. A messaging latency\n# histogram that stops at 10 seconds is measuring the wrong system; real\n# operator-side delivery routinely has a multi-minute tail and you need\n# to see it, because that tail is what your OTP users experience.\nDELIVERY_LATENCY = Histogram(\n    \"messaging_delivery_latency_seconds\",\n    \"Gap between platform submitTime and deliveryTime.\",\n    &#91;\"channel\", \"network\", \"message_class\"],\n    buckets=(1, 2, 5, 10, 20, 30, 60, 120, 300, 600, 1800, 3600),\n)\n\nTS_FORMAT = \"%Y-%m-%d %H:%M:%S\"\n\n\ndef poll_dlr(session, *, userid, password, fromdate, todate, page_limit=500):\n    \"\"\"Yield reports_dlrList rows, following the cursor to exhaustion.\"\"\"\n    cursor = None\n    while True:\n        payload = {\n            \"method\": \"getDlr\",\n            \"userid\": userid,\n            \"password\": password,\n            \"fromdate\": fromdate,\n            \"todate\": todate,\n            \"pageLimit\": str(page_limit),\n            \"output\": \"json\",\n        }\n        if cursor:\n            payload&#91;\"startCursor\"] = cursor\n\n        # Safe to retry: this is a read endpoint.\n        response = session.post(DLR_URL, data=payload, timeout=(5, 30))\n        body = response.json()\n        rows = body.get(\"reports_dlrList\") or &#91;]\n        if not rows:\n            return\n\n        for row in rows:\n            yield row\n\n        cursor = rows&#91;-1].get(\"cursorId\")\n        if not cursor or len(rows) &lt; page_limit:\n            return\n\n\ndef record(row, message_class_lookup):\n    \"\"\"Turn one DLR row into metrics. Bounded labels only.\"\"\"\n    status = str(row.get(\"status\", \"unknown\")).lower()\n    cause = str(row.get(\"cause\", \"\") or \"none\")\n    network = str(row.get(\"network\", \"\") or \"unknown\")\n    senderid = str(row.get(\"senderName\", \"\") or \"unknown\")\n    channel = str(row.get(\"channel\", \"sms\")).lower()\n\n    DELIVERY_OUTCOME.labels(\n        channel=channel, status=status, cause=cause,\n        network=network, senderid=senderid,\n    ).inc()\n\n    # Decimal, not float.\n    raw_cost = row.get(\"cost\")\n    if raw_cost not in (None, \"\"):\n        DELIVERY_COST.labels(\n            channel=channel, status=status, senderid=senderid,\n        ).inc(float(Decimal(str(raw_cost))))\n\n    submit_time = row.get(\"submitTime\")\n    delivery_time = row.get(\"deliveryTime\")\n    if submit_time and delivery_time:\n        try:\n            gap = (datetime.strptime(delivery_time, TS_FORMAT)\n                   - datetime.strptime(submit_time, TS_FORMAT)).total_seconds()\n        except (ValueError, TypeError):\n            return\n        # Negative gaps happen. Clock skew between platform components,\n        # or a receipt for a message submitted before your window opened.\n        # Drop them rather than letting a negative observation corrupt\n        # the histogram.\n        if gap &gt;= 0:\n            DELIVERY_LATENCY.labels(\n                channel=channel,\n                network=network,\n                message_class=message_class_lookup(row.get(\"msgId\")),\n            ).observe(gap)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Notice what is deliberately absent from every label set: <code>mobileNo<\/code>, <code>msgId<\/code>, <code>uuId<\/code> and <code>text<\/code>. Those are per-message identifiers and message content. They belong in your logs and your database, never in a metric label. The next section explains why in concrete terms.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notice also the <code>message_class_lookup<\/code> call. The delivery report does not know whether a message was an OTP or a marketing blast; that is your business classification. You have to join it back from your own records using <code>msgId<\/code> or <code>uuId<\/code>. This join is the single most valuable piece of enrichment in the whole pipeline, because without it every per-class SLO in this article is unimplementable. Build the lookup as a bounded cache keyed on message ID, and if the lookup misses, label the class as <code>unknown<\/code> rather than dropping the observation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The PromQL that turns those counters into the funnel<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># Delivery rate, computed against SUBMITTED. Note the denominator.\nsum by (message_class) (\n  rate(messaging_delivery_outcome_total{status=\"delivered\"}&#91;30m])\n)\n\/\nsum by (message_class) (\n  rate(messaging_submitted_units_total&#91;30m])\n)\n\n# Pending rate. This is the metric that catches a dead DLR pipeline.\n# It should be small and stable. A sustained climb means receipts have\n# stopped arriving, even though everything else looks fine.\n1 - (\n  sum by (message_class) (rate(messaging_delivery_outcome_total&#91;30m]))\n  \/\n  sum by (message_class) (rate(messaging_submitted_units_total&#91;30m]))\n)\n\n# Effective cost per DELIVERED message.\nsum by (senderid) (rate(messaging_delivery_cost_total&#91;1h]))\n\/\nsum by (senderid) (rate(messaging_delivery_outcome_total{status=\"delivered\"}&#91;1h]))\n\n# p95 of operator-side delivery latency. Not the mean. Never the mean.\nhistogram_quantile(0.95,\n  sum by (le, message_class) (rate(messaging_delivery_latency_seconds_bucket&#91;30m]))\n)\n\n# Ambiguous-outcome ratio. Leading indicator for submission-path strain.\nsum(rate(messaging_ambiguous_outcomes_total&#91;5m]))\n\/\nsum(rate(messaging_submit_outcome_total&#91;5m]))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The second query deserves emphasis. Pending rate expressed as one minus the terminal-outcome rate is the closest thing this article offers to a single number that says &#8220;your delivery observability is working&#8221;. It rises when receipts stop arriving, it rises when your poller falls behind, and it rises when the operator is genuinely slow. All three are things you want to know about, and none of them show up on a submission dashboard.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-dashboard-analytics-api\" class=\"wp-block-heading\">The Dashboard Analytics API as a Cross-Check<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There is a consolidated analytics endpoint that most integrations never discover: <code>POST<\/code> or <code>GET<\/code> to <code>https:\/\/unify.smsgateway.center\/SMSApi\/dashboard\/read<\/code>, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/dashboard\/\">Dashboard Analytics API page<\/a>. It takes <code>startDate<\/code> and <code>endDate<\/code> in <code>YYYY-MM-DD<\/code> form, both optional and defaulting to today, with a documented maximum range of 31 days, plus <code>output<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It returns four sections under <code>response.dashboard<\/code>:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Section<\/th><th>Contents<\/th><th>Observability use<\/th><\/tr><\/thead><tbody><tr><td><code>topBar<\/code><\/td><td><code>balance<\/code>, <code>balanceRaw<\/code>, <code>balanceCurrency<\/code>, <code>balanceCredits<\/code>, <code>lastPurchaseAmount<\/code>, <code>lastPurchaseDate<\/code>, <code>groups<\/code>, <code>contacts<\/code>, <code>creditType<\/code><\/td><td>Balance as a first-class alertable metric, and burn rate<\/td><\/tr><tr><td><code>sentSummary<\/code><\/td><td><code>data[]<\/code> of <code>{date, dateFormatted, submitted, delivered, failed, pending}<\/code> plus <code>totalSubmitted<\/code>, <code>averagePerDay<\/code>, <code>startDate<\/code>, <code>endDate<\/code>, <code>days<\/code><\/td><td>Daily reconciliation against your own counters<\/td><\/tr><tr><td><code>donutInfo<\/code><\/td><td><code>data[]<\/code> of <code>{label, value, labelColor}<\/code> across Submitted, Delivered, Failed, Pending and Rejected, plus the date range<\/td><td>Whole-period status breakdown<\/td><\/tr><tr><td><code>chartColors<\/code><\/td><td>Colour assignments per status<\/td><td>Presentation only, ignore for metrics<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three practical notes, and one of them is a genuine trap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use it as a cross-check, not as your primary source.<\/strong> It is a daily-granularity aggregate. You cannot alert on a five-minute window with it, you cannot get per-message detail from it, and you cannot join it to your own message classes. Your primary delivery data must come from <code>reports_dlrList<\/code>. What this endpoint gives you is an independent second opinion, computed platform-side, against which you can validate your own aggregation. If your locally computed daily delivered count and the platform&#8217;s <code>sentSummary<\/code> delivered count for the same day differ by more than a rounding margin, one of the two is wrong and you want to find out which before you make a decision based on either.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The trap: <code>sentSummary<\/code> daily rows and <code>donutInfo<\/code> do not count the same status set.<\/strong> Each daily row in <code>sentSummary<\/code> carries <code>submitted<\/code>, <code>delivered<\/code>, <code>failed<\/code> and <code>pending<\/code>. There is no <code>rejected<\/code> field on a daily row. But <code>donutInfo<\/code> reports five labels including Rejected. If you sum the daily rows and compare that total to the donut, they will not agree whenever any messages were rejected, and the discrepancy will look like a data integrity bug in your own code. It is not. It is a difference in what each section counts. Reconcile against the section that carries the field you are checking, and never assume one is a rollup of the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Balance is an alertable metric and almost nobody alerts on it.<\/strong> <code>topBar.balanceRaw<\/code> gives you a numeric balance and <code>creditType<\/code> tells you whether the account is credit-based or wallet-based, which changes what the number means. Running out of credit mid-campaign produces a failure that looks exactly like a platform outage from inside your application: sends start failing for a reason your error classifier has probably never seen. Two alerts fix this permanently. One on absolute balance below a floor. One on projected time to exhaustion, computed as balance divided by recent daily burn from <code>sentSummary.averagePerDay<\/code>, firing when it drops below your procurement lead time. The second is far more useful than the first, because a floor that is right for a quiet week is wrong for a campaign week.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"\nNightly cross-check and balance projection.\nChunk your date range: the endpoint documents a 31-day maximum.\n\"\"\"\nfrom decimal import Decimal\n\nDASHBOARD_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/dashboard\/read\"\n\n\ndef cross_check(session, *, api_key, start_date, end_date, local_daily_counts):\n    response = session.post(\n        DASHBOARD_URL,\n        headers={\"apikey\": api_key},\n        data={\"startDate\": start_date, \"endDate\": end_date, \"output\": \"json\"},\n        timeout=(5, 30),\n    )\n    dashboard = response.json()&#91;\"response\"]&#91;\"dashboard\"]\n\n    discrepancies = &#91;]\n    for row in dashboard&#91;\"sentSummary\"]&#91;\"data\"]:\n        day = row&#91;\"date\"]\n        platform_submitted = int(row&#91;\"submitted\"])\n        local_submitted = local_daily_counts.get(day, 0)\n        if platform_submitted == 0 and local_submitted == 0:\n            continue\n        drift = abs(platform_submitted - local_submitted)\n        denominator = max(platform_submitted, local_submitted, 1)\n        if drift \/ denominator &gt; 0.01:      # tolerate 1 percent\n            discrepancies.append({\n                \"date\": day,\n                \"platform\": platform_submitted,\n                \"local\": local_submitted,\n                \"drift_pct\": round(100 * drift \/ denominator, 2),\n            })\n\n    # Balance projection. Far more actionable than a static floor.\n    balance = Decimal(str(dashboard&#91;\"topBar\"]&#91;\"balanceRaw\"]))\n    per_day = Decimal(str(dashboard&#91;\"sentSummary\"]&#91;\"averagePerDay\"] or 0))\n    days_remaining = float(balance \/ per_day) if per_day &gt; 0 else float(\"inf\")\n\n    return {\n        \"discrepancies\": discrepancies,\n        \"balance_raw\": balance,\n        \"credit_type\": dashboard&#91;\"topBar\"]&#91;\"creditType\"],\n        \"projected_days_remaining\": days_remaining,\n    }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you want the human-facing version of this data rather than the programmatic one, the platform ships a built-in analytics view covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/customer-insights-analytics\/\">Customer Insights and Analytics<\/a>, which walks through template performance, campaign performance and error breakdowns in the control panel. That article and this one are complements: it explains the dashboard you are given, this one explains the dashboard you have to build. It is also worth knowing the platform distinguishes between a per-message delivery report and an aggregated delivery summary, explained in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-the-difference-between-sms-delivery-report-and-sms-delivery-summary\/\">the knowledge base entry on the difference<\/a>, because picking the wrong one is a common reason people conclude data is missing.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"error-codes-turning-failed\" class=\"wp-block-heading\">Error Codes: Turning &#8220;Failed&#8221; Into Something Actionable<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>failed<\/code> counter with no breakdown is a smoke alarm with no location. The <code>cause<\/code> and <code>globalErrorCode<\/code> fields on each delivery report row are what turn it into a diagnosis, and the platform publishes both code lists as live endpoints.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/responsecodes<\/code> returns <code>responsecodesList<\/code>, an array of <code>{errorcode, httpCode, status, description}<\/code>. Documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API response error code list page<\/a>.<\/li>\n\n\n\n<li><code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/deliverycodes<\/code> returns <code>deliverycodesList<\/code>, an array of <code>{peId, identifier, status, cause}<\/code>. Documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">delivery error code list page<\/a>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Both are read-only and free to call, which makes them the best available live contract surface. Two things follow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Run a nightly diff against the codes your application handles.<\/strong> Fetch both lists, compare against your classifier&#8217;s known set, and fail the job if a code appears that you have no handling for. A new code that your classifier does not recognise is a silent bucket of failures that will accumulate as <code>unknown<\/code> and never trigger anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Default unknown codes to permanent, never to transient.<\/strong> This is a rule with a specific financial justification. If an unrecognised code defaults to transient, your retry logic will resubmit the message, be billed again, receive the same unrecognised code again, and resubmit again. That is an infinite billing loop driven by a code list update you did not notice. Defaulting to permanent means an unrecognised code produces an undelivered message and an alert, which is a bounded and correctable failure. Choose the bounded one.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"\nNightly contract check against the two live code lists.\nExits non-zero in CI when the platform knows a code your code does not.\n\"\"\"\nimport sys\n\nRESPONSE_CODES_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/info\/responsecodes\"\nDELIVERY_CODES_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/info\/deliverycodes\"\n\n# The codes your classifier explicitly handles. Keep this in version control\n# next to the classifier itself so the two cannot drift apart.\nHANDLED_RESPONSE_CODES = {\"200\", \"188\", \"300\", \"301\"}\nHANDLED_DELIVERY_CAUSES = {\n    \"DELIVRD\", \"ABSENT_SUB\", \"OPERATOR_DOWN\", \"DUPLICATE_FAIL\",\n    \"TEMPLATE_CHECK_FAILED\", \"DLT_REJECTED\", \"SYSTEM_FAIL\",\n    \"BLOCKED_SENDERID\", \"INVALID_SENDERID\", \"CONSENT_FAILED\", \"IN_RETRY\",\n}\n\n\ndef check(session, api_key):\n    unhandled = &#91;]\n\n    r = session.post(RESPONSE_CODES_URL, headers={\"apikey\": api_key},\n                     data={\"output\": \"json\"}, timeout=(5, 30)).json()\n    for entry in r.get(\"responsecodesList\", &#91;]):\n        code = str(entry.get(\"errorcode\", \"\")).strip()\n        if code and code not in HANDLED_RESPONSE_CODES:\n            unhandled.append((\"response\", code, entry.get(\"description\", \"\")))\n\n    d = session.post(DELIVERY_CODES_URL, headers={\"apikey\": api_key},\n                     data={\"output\": \"json\"}, timeout=(5, 30)).json()\n    for entry in d.get(\"deliverycodesList\", &#91;]):\n        identifier = str(entry.get(\"identifier\", \"\")).strip().upper()\n        if identifier and identifier not in HANDLED_DELIVERY_CAUSES:\n            unhandled.append((\"delivery\", identifier, entry.get(\"cause\", \"\")))\n\n    if unhandled:\n        print(\"Unhandled codes detected. Classify each before it appears \"\n              \"in production traffic:\")\n        for kind, code, description in unhandled:\n            print(f\"  &#91;{kind}] {code}: {description}\")\n        sys.exit(1)\n\n    print(f\"Contract OK. {len(HANDLED_RESPONSE_CODES)} response codes and \"\n          f\"{len(HANDLED_DELIVERY_CAUSES)} delivery causes accounted for.\")\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>HANDLED_DELIVERY_CAUSES<\/code> set above is seeded from causes documented in the knowledge base, each of which has a dedicated entry worth reading when it first appears in your data: <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/dlr-status-meaning\/\">what DELIVRD and the other DLR statuses mean<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-absent_sub-absent-subscriber-unreachable-mean\/\">absent subscriber<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-operator_down-operator-unreachable-mean\/\">operator unreachable<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-template_check_failed-template-mismatch-mean\/\">template mismatch<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-duplicate_fail-duplicate-fail-mean\/\">duplicate fail<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-in_retry-retry-attempted-mean\/\">retry attempted<\/a>, and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-dlt_rejected-mean\/\">DLT rejected<\/a>. Treat the list in your code as the contract and the knowledge base as the explanation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The India-specific failure signature worth its own panel<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you send in India under DLT, one failure class deserves a dedicated panel rather than sitting inside a general error breakdown: template scrubbing failures. They have a distinctive signature that no other failure shares.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The signature is a <strong>sudden, total, sender-ID-scoped failure with a template-related cause<\/strong>. Not a partial degradation, not a network-scoped issue. Every message using one template starts failing at once, usually immediately after someone edited that template&#8217;s content, changed a sender ID binding, or switched providers. The cause field carries a template or DLT identifier, and the API-side equivalent is status code 188, message template mismatch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Build the panel as a breakdown of failure count by <code>dltTemplateId<\/code>, which the delivery report conveniently carries on every row. A single template ID going from zero failures to one hundred percent failures inside one minute is unmistakable on that panel and nearly invisible on an aggregate error graph, because one template out of two hundred failing completely may only move the global error rate by half a percent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The cure is upstream of observability: validate before you send. <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/validateTemplate<\/code> with <code>senderid<\/code>, <code>msg<\/code> and <code>dltTemplateId<\/code> returns a clean success or a <code>188<\/code> mismatch, and running it in CI over your template corpus converts a production incident into a failed build. The full treatment, including the twenty-four-row rejection reason table and the character substitution traps that cause most of these, is in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT Template Rejected: Every Reason and How to Fix It<\/a>, and the registration and binding side is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-registration-step-by-step-guide-india\/\">DLT registration step-by-step guide<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"cardinality\" class=\"wp-block-heading\">Cardinality: The Label That Will Take Down Your Metrics Backend<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Cardinality is the number of distinct label combinations a metric produces. Every combination is a separate time series with its own memory and storage cost. In most systems this stays manageable by accident. In messaging it does not, because the most obviously useful label is also unbounded.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Labelling a metric with the recipient mobile number produces one time series per recipient. A single campaign to two hundred thousand recipients produces two hundred thousand series in one afternoon, each of which is retained for your full retention window, each of which is queried by every dashboard that touches that metric. This is not a slow degradation. It is an outage of your monitoring system, caused by your monitoring system, during a campaign, which is exactly when you need it. The same applies to transaction IDs, message IDs and anything derived from message content.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Label<\/th><th>Cardinality<\/th><th>Verdict<\/th><th>Reasoning<\/th><\/tr><\/thead><tbody><tr><td><code>channel<\/code><\/td><td>Under 10<\/td><td>Safe<\/td><td>sms, whatsapp, voice, rcs<\/td><\/tr><tr><td><code>message_class<\/code><\/td><td>Under 20<\/td><td>Safe and essential<\/td><td>Every per-class SLO depends on it<\/td><\/tr><tr><td><code>senderid<\/code><\/td><td>Tens<\/td><td>Safe<\/td><td>Bounded by your registered sender IDs<\/td><\/tr><tr><td><code>status<\/code><\/td><td>Under 10<\/td><td>Safe<\/td><td>Terminal outcome set<\/td><\/tr><tr><td><code>cause<\/code><\/td><td>Tens<\/td><td>Safe<\/td><td>Bounded by the published delivery code list<\/td><\/tr><tr><td><code>encoding<\/code><\/td><td>2<\/td><td>Safe and valuable<\/td><td>TEXT versus UNICODE catches segment inflation<\/td><\/tr><tr><td><code>network<\/code><\/td><td>Tens<\/td><td>Safe in one country<\/td><td>Verify before enabling for international traffic<\/td><\/tr><tr><td><code>country<\/code><\/td><td>Around 200 worst case<\/td><td>Borderline<\/td><td>Fine alone, dangerous combined with <code>network<\/code> and <code>cause<\/code><\/td><\/tr><tr><td><code>dltTemplateId<\/code><\/td><td>Hundreds<\/td><td>Borderline, usually worth it<\/td><td>The scrubbing panel needs it. Bound it explicitly<\/td><\/tr><tr><td><code>mobileNo<\/code><\/td><td>Unbounded<\/td><td><strong>Never<\/strong><\/td><td>One series per recipient<\/td><\/tr><tr><td><code>transactionId<\/code><\/td><td>Unbounded<\/td><td><strong>Never<\/strong><\/td><td>One series per request<\/td><\/tr><tr><td><code>msgId<\/code> \/ <code>uuId<\/code><\/td><td>Unbounded<\/td><td><strong>Never<\/strong><\/td><td>One series per message<\/td><\/tr><tr><td><code>text<\/code> \/ <code>msg<\/code><\/td><td>Unbounded<\/td><td><strong>Never<\/strong><\/td><td>Content in a label is also a privacy incident<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The rule that keeps this safe permanently: <strong>anything that identifies an individual message or an individual recipient goes in a log line or a database row, never in a metric label.<\/strong> Metrics answer &#8220;how many and how bad&#8221;. Logs and traces answer &#8220;which one&#8221;. Keeping that boundary clean is what lets both scale.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cardinality is multiplicative, which is the part people miss. Ten channels times twenty classes times fifty sender IDs times thirty causes is three hundred thousand potential series from four individually reasonable labels. Before adding a label, multiply it against the existing set and look at the number. If your metrics backend supports it, put a hard series limit per metric in place so an accidental unbounded label degrades one metric instead of the whole system.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For <code>dltTemplateId<\/code> specifically, which is genuinely useful for the scrubbing panel, bound it explicitly: maintain an allowlist of your active template IDs and label anything outside it as <code>other<\/code>. That keeps the panel useful while making the cardinality a number you chose rather than a number the data chose for you.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"alerting-ratios-not-counts\" class=\"wp-block-heading\">Alerting on Ratios, Not Counts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An alert on an absolute count fires every time you launch a campaign. That is not a tuning problem you can solve with a higher threshold, because the range of legitimate volume in a messaging system spans several orders of magnitude within the same day. Any threshold high enough to survive a campaign is too high to catch a quiet-hours failure, and any threshold low enough to catch the quiet-hours failure will page you on every campaign until someone silences it permanently. Count-based alerting on messaging systems ends in a silenced alert, always.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ratios are volume-independent, which is the entire point.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Alert<\/th><th>Expression<\/th><th>Suggested window<\/th><th>Why this shape<\/th><\/tr><\/thead><tbody><tr><td>Delivery rate drop<\/td><td><code>delivered \/ submitted<\/code> below a per-class baseline<\/td><td>30 minutes<\/td><td>Volume-independent. Catches route and scrubbing problems<\/td><\/tr><tr><td>Pending climb<\/td><td><code>pending \/ submitted<\/code> above baseline<\/td><td>15 minutes<\/td><td>The DLR-pipeline-is-dead alarm. Nothing else catches it<\/td><\/tr><tr><td>Ambiguous surge<\/td><td><code>ambiguous \/ total_attempts<\/code> above baseline<\/td><td>5 minutes<\/td><td>Leading indicator, rises before the error rate<\/td><\/tr><tr><td>Oldest item age<\/td><td>Absolute seconds, per class<\/td><td>2 minutes<\/td><td>The one legitimate absolute threshold, because its unit is user patience<\/td><\/tr><tr><td>Sustained backlog growth<\/td><td><code>arrival_rate &gt; drain_rate<\/code> continuously<\/td><td>Longer than your longest campaign spike<\/td><td>Distinguishes a burst from an inversion<\/td><\/tr><tr><td>Cost per delivered<\/td><td>Percentage change against a 7-day baseline<\/td><td>6 hours<\/td><td>Slow-moving. Catches segment inflation and retry storms<\/td><\/tr><tr><td>Template scrubbing<\/td><td>Failure ratio per <code>dltTemplateId<\/code> above a high threshold<\/td><td>5 minutes<\/td><td>Near-binary failure, so a high threshold is safe and precise<\/td><\/tr><tr><td>Reconciliation backlog<\/td><td>Oldest unreconciled record age<\/td><td>30 minutes<\/td><td>Bounds billing exposure<\/td><\/tr><tr><td>Balance runway<\/td><td>Projected days remaining below procurement lead time<\/td><td>12 hours<\/td><td>Prevents an outage that looks like a platform failure<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two design points about that table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Baselines beat constants.<\/strong> Comparing against a seven-day-ago value at the same hour of day handles weekday and weekend traffic shape for free, where a constant does not. Messaging traffic is intensely diurnal and weekly, so a constant threshold is implicitly a threshold for one hour of one day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Oldest item age is deliberately the exception.<\/strong> It is the one metric where an absolute threshold is correct, because its unit is seconds of user waiting and user patience does not scale with your traffic volume. Thirty seconds is thirty seconds whether you sent ten messages or ten million.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"per-message-class-slos\" class=\"wp-block-heading\">Per-Message-Class SLOs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A single global SLO across all messaging traffic is arithmetic that describes no real user. It will be dominated by whichever class has the most volume, which is almost always marketing, which is the class where nobody cares. Split by class, and make the SLO measure the thing that class actually promises.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Message class<\/th><th>Primary SLO<\/th><th>Secondary SLO<\/th><th>Alert posture<\/th><th>Behaviour under overload<\/th><\/tr><\/thead><tbody><tr><td><strong>OTP<\/strong><\/td><td>Latency. p95 of enqueue-to-delivery under a target measured in seconds<\/td><td>Delivery rate<\/td><td>Page immediately<\/td><td><strong>Shed.<\/strong> A late OTP is a failed OTP and guarantees a user-triggered resend, which means a second charge. Failing fast is cheaper than delivering late<\/td><\/tr><tr><td><strong>Transactional alerts<\/strong><\/td><td>Delivery rate<\/td><td>Latency at minute scale<\/td><td>Page during business hours<\/td><td>Buffer briefly, then shed. A shipment alert has a window, but it is minutes not seconds<\/td><\/tr><tr><td><strong>Account and security notices<\/strong><\/td><td>Delivery rate, high target<\/td><td>Latency, loose<\/td><td>Page<\/td><td>Buffer. Late is acceptable, missing is not<\/td><\/tr><tr><td><strong>Scheduled reminders<\/strong><\/td><td>Delivery rate within the scheduled window<\/td><td>None<\/td><td>Ticket<\/td><td>Buffer. The whole class is time-shifted by design<\/td><\/tr><tr><td><strong>Marketing and promotional<\/strong><\/td><td>Cost per delivered message<\/td><td>Delivery rate, loose<\/td><td>Ticket only<\/td><td>Buffer indefinitely, and shed before any other class<\/td><\/tr><tr><td><strong>Two-way and conversational<\/strong><\/td><td>Round-trip latency<\/td><td>Session window adherence<\/td><td>Page<\/td><td>Shed. A reply outside the session window has different mechanics and cost<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The OTP row is the inversion and it is worth stating explicitly because it contradicts the default instinct in every other queueing context. Everywhere else, buffering is the polite response to overload: hold the work, deliver it late, nobody is harmed. For OTP, buffering is actively harmful. A user who does not receive a code within roughly thirty seconds presses resend. You now pay for a second message. If the first one arrives after the resend, the user may enter a code that has already been superseded, and you have paid twice to create a support ticket. Shedding, and returning a clean error your application can present as &#8220;please try again&#8221;, costs one message and produces a better outcome. The delivery characteristics that make OTP a distinct route in the first place are described on the <a href=\"https:\/\/www.smsgatewaycenter.com\/otp-sms-india\/\">OTP SMS product page<\/a>, and the generate and verify endpoints are documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">generate OTP<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/verify-otp\/\">verify OTP<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One note on targets. This article deliberately states no specific numeric SLO target, no delivery percentage and no throughput figure. Those numbers depend on your routes, your destinations, your traffic mix and your commercial arrangement, and any figure quoted here would be a fabricated benchmark. Derive your targets from your own measured baseline over a representative period, and set the SLO slightly tighter than the baseline so it has room to detect regression. A target copied from a marketing benchmark article is not an SLO, it is a decoration.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-reconciliation-backlog\" class=\"wp-block-heading\">The Reconciliation Backlog Is a Deferred Billing Dispute<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every ambiguous send produces a record in an <code>unknown<\/code> state. Reconciliation is the process that resolves each one to a definite outcome by looking it up in the delivery report. Most teams build the ambiguous-state handling, feel appropriately pleased about it, and then never measure whether the reconciliation actually keeps up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is why that matters in money rather than in tidiness. Each unresolved record represents a message you may have paid for and cannot account for. A thousand of them is a thousand messages of unexplained spend. If your reconciliation sweep is slower than your ambiguous-outcome rate, that backlog grows without bound, and by the time anyone looks at it the delivery report window you would need to resolve the oldest entries has closed. At that point the records are permanently unresolvable. You have converted a solvable data problem into a permanent line item of unexplained spend, which is what a deferred billing dispute is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three metrics, all mandatory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>UNKNOWN_BACKLOG = Gauge(\n    \"messaging_unknown_backlog_size\",\n    \"Count of records in unknown state awaiting reconciliation.\",\n    &#91;\"message_class\"],\n)\n\nOLDEST_UNRECONCILED = Gauge(\n    \"messaging_oldest_unreconciled_age_seconds\",\n    \"Age of the oldest record still in unknown state.\",\n    &#91;\"message_class\"],\n)\n\nRECONCILED = Counter(\n    \"messaging_reconciled_total\",\n    \"Records resolved out of unknown state, by resolution.\",\n    # resolution: found_delivered, found_failed, not_found_assumed_lost\n    &#91;\"message_class\", \"resolution\"],\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Alert on <code>messaging_oldest_unreconciled_age_seconds<\/code> crossing a threshold set well inside your delivery report retention window. Not on backlog size. Size tells you how much work is queued; age tells you whether you are about to lose the ability to do it at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>not_found_assumed_lost<\/code> resolution deserves a comment. If a record has been unknown for longer than any plausible delivery latency and no matching delivery report row exists, the most likely explanation is that the request never reached the platform, so you were not billed. That is a reasonable operational conclusion but it is an inference, not an observation. Count it separately, never fold it into <code>found_failed<\/code>, and watch its ratio. A rising <code>not_found_assumed_lost<\/code> rate means your matching logic is broken, your polling window is too narrow, or your correlation keys are wrong, and all three are bugs that will silently corrupt every delivery number you compute.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Matching a delivery report row back to your own record needs care, because the identifiers do not line up as neatly as you would like. The send response carries <strong>one<\/strong> top-level <code>transactionId<\/code> per request, not one per recipient, so for any multi-recipient send that field cannot identify an individual message. The delivery report rows carry <code>msgId<\/code>, <code>uuId<\/code> and <code>mobileNo<\/code>. Match on the combination of recipient, sender ID and a submit time window, then confirm with <code>msgId<\/code>. The full state machine, including the rule that an unknown record never transitions back to pending, is in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">Message Idempotency at Scale<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"structured-logging-redaction\" class=\"wp-block-heading\">Structured Logging and Redaction<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Metrics tell you something is wrong. Logs tell you which message. Both are needed, and the boundary between them is what keeps cardinality sane.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every log line on the send path should carry a consistent field set:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Field<\/th><th>Example<\/th><th>Purpose<\/th><\/tr><\/thead><tbody><tr><td><code>business_key<\/code><\/td><td><code>order-88213-shipped<\/code><\/td><td>Your deterministic idempotency key. Ties every attempt for one logical message together<\/td><\/tr><tr><td><code>attempt_id<\/code><\/td><td>UUID<\/td><td>Per-attempt trace key. Distinguishes attempts of the same logical message<\/td><\/tr><tr><td><code>message_class<\/code><\/td><td><code>otp<\/code><\/td><td>Enables per-class log filtering that matches your per-class metrics<\/td><\/tr><tr><td><code>channel<\/code><\/td><td><code>sms<\/code><\/td><td>Cross-channel correlation<\/td><\/tr><tr><td><code>senderid<\/code><\/td><td><code>SGCTST<\/code><\/td><td>Sender-scoped investigation<\/td><\/tr><tr><td><code>recipient_hash<\/code><\/td><td>First 12 hex characters of a salted SHA-256 of the number<\/td><td>Correlate on a recipient <strong>without storing the number<\/strong><\/td><\/tr><tr><td><code>transaction_id<\/code><\/td><td>From the send response<\/td><td>Joins to platform-side records<\/td><\/tr><tr><td><code>msg_id<\/code><\/td><td>From the delivery report<\/td><td>Joins the two clocks together<\/td><\/tr><tr><td><code>outcome<\/code><\/td><td><code>accepted<\/code><\/td><td>Matches the metric label exactly, so a graph spike leads straight to the log query<\/td><\/tr><tr><td><code>latency_ms<\/code><\/td><td><code>342<\/code><\/td><td>Per-request detail behind the histogram<\/td><\/tr><tr><td><code>units<\/code><\/td><td><code>6<\/code><\/td><td>Recipients times segments, for cost attribution<\/td><\/tr><tr><td><code>dlt_template_id<\/code><\/td><td><code>1707161234567890123<\/code><\/td><td>India scrubbing investigation<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three redaction rules, non-negotiable:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never log the message body at full fidelity.<\/strong> It contains the substituted values of every DLT variable, which in practice means names, amounts, order numbers, addresses and one-time codes. Log the template ID and the rendered length. If you must log something about the content for debugging, log a hash of it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never log credentials, and be aware that the failure mode here is not the obvious one.<\/strong> Nobody deliberately logs a password. What happens is that a debug logger dumps a whole request object, and if credentials are passed as request parameters they are in that object. Both <code>userid<\/code>\/<code>password<\/code> and the <code>apikey<\/code> header are affected. Implement redaction as a logging filter that operates on the serialised output, not as discipline applied at each call site, because discipline at call sites fails the first time someone adds a new call site. The language tutorials for <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python<\/a> both carry a concrete implementation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Hash recipient numbers, do not truncate them.<\/strong> Truncation to the last four digits feels safe and is not, because it is trivially reversible when combined with any other data you hold. A salted hash gives you the correlation ability you actually wanted with none of the exposure.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbound-observability\" class=\"wp-block-heading\">Inbound Observability: Watching Your Own Webhook Receiver<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you receive delivery receipts by webhook rather than polling, your receiver is now part of the pipeline and needs its own instrumentation. It is the most commonly uninstrumented component in any messaging integration, and it fails in a way that is specifically designed to be invisible: a broken receiver that still returns HTTP 200 will cause receipts to be discarded silently and forever, while the platform&#8217;s own view of the world says everything was delivered successfully to your endpoint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Register a webhook with <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code>, passing <code>smswebhook<\/code> as your URL and <code>smswebhookrate<\/code>, documented verbatim as &#8220;DLR TPS. AT what TPS rate DLR should be forwarded to. Default is 10.&#8221; The full parameter set is on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-webhook\/\">Create Webhook API page<\/a>, and the control panel walkthrough is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/setup-webhook-sms-whatsapp-dlr\/\">webhook setup knowledge base entry<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That rate parameter is your inbound flow control and it belongs in your capacity planning. If your receiver can comfortably process well above the configured TPS, you have headroom. If it cannot, you will produce timeouts on the platform side and lose receipts, which manifests to you as a mysteriously elevated pending rate with no other symptom.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Four metrics for the receiver:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>WEBHOOK_RECEIVED = Counter(\n    \"messaging_webhook_received_total\",\n    \"Delivery receipt callbacks received.\",\n    &#91;\"outcome\"],   # accepted, auth_failed, parse_failed, unmatched\n)\n\nWEBHOOK_LATENCY = Histogram(\n    \"messaging_webhook_handler_latency_seconds\",\n    \"Time to acknowledge an inbound callback.\",\n    buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0),\n)\n\nWEBHOOK_SILENCE = Gauge(\n    \"messaging_webhook_seconds_since_last_receipt\",\n    \"Seconds since the last inbound callback of any kind.\",\n)\n\nWEBHOOK_UNMATCHED = Counter(\n    \"messaging_webhook_unmatched_total\",\n    \"Callbacks that could not be matched to a known outbound record.\",\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>WEBHOOK_SILENCE<\/code> is the one that matters most and the one nobody builds.<\/strong> A receiver that has received nothing for an hour, while your application has been submitting continuously the whole time, is broken. Not slow, not degraded: broken. This gauge catches a dead receiver, a revoked registration, a DNS change, an expired certificate and a firewall rule change, all of which produce identical silence and none of which produce an error anywhere in your system. It is three lines of code and it is the highest-value alert in this section.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two implementation notes drawn from receivers that have failed in practice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Read the raw body before any framework parses it.<\/strong> A global JSON body parser applied to an endpoint that receives form-encoded data will silently produce an empty parsed object, your handler will find no fields, and it will return 200 because nothing threw. The platform sees success. You see nothing. Read raw bytes first, persist them, then parse, so that even a total parsing failure leaves you with recoverable data and a <code>parse_failed<\/code> metric increment rather than silence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Acknowledge fast, process asynchronously.<\/strong> The handler latency histogram should live entirely in the low milliseconds. If you do database writes, enrichment or business logic inline, you will eventually exceed the platform&#8217;s tolerance under load and lose receipts. Persist raw, return 200, process on a worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The push payload field names are not publicly documented, so do not hardcode assumptions about them. Capture a real callback on your own endpoint first, record the exact schema in your repository as a fixture, and write your parser against that observed fixture. This is the same probe-first discipline that applies to any undocumented integration surface. The broader webhook architecture, including signature verification, is covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">Real-Time DLR Webhooks<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"dashboard-layout\" class=\"wp-block-heading\">Dashboard Layout: Four Panels, In This Order<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Panel ordering is not cosmetic. During an incident people read top-left first and stop reading about four panels in. Put the leading indicators where they will be seen and the volume charts where they will not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Row one, left: oldest item age by message class.<\/strong> Four lines, one per class, with a horizontal threshold marker per class. This is the panel that should tell an on-call engineer within two seconds whether anything is wrong. It goes first because it is the only panel whose units are directly comparable to user patience.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Row one, right: the two clocks side by side.<\/strong> Submit success ratio and delivery rate over submitted, on the same time axis, explicitly annotated to indicate that the delivery line lags. This is the panel that makes the &#8220;healthy submission, degraded delivery&#8221; quadrant visible. Add pending rate as a third line here rather than hiding it elsewhere, because its climb is the DLR-pipeline alarm.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Row two, left: outcome breakdown, stacked, by cause.<\/strong> Not a total failure count. A stacked area by cause, so the shape of the failure tells you the category before you have read any labels. A single cause dominating a spike means a route or template problem. Many causes rising together means a broad platform or network issue.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Row two, right: cost per delivered message, with the rate card as a reference line.<\/strong> The gap between the two lines is your waste, drawn to scale. This is the panel that gets a finance conversation moving faster than any spreadsheet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Everything else goes below the fold.<\/strong> Total volume, per-sender-ID breakdowns, network distribution, geographic splits, balance. All useful for investigation, none useful for detection. If a panel cannot change what you do in the first sixty seconds of an incident, it is not a detection panel.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One addition that pays for itself: annotate the dashboard with campaign launch events. Most large step changes in messaging metrics are caused by someone launching something, and an unannotated dashboard makes every campaign look like an incident for the first ten minutes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"incident-review\" class=\"wp-block-heading\">Incident Review: Symptom to the Metric That Would Have Caught It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The practical test of an observability setup is not whether it produces graphs but whether, for each incident you have had, there existed a metric that would have surfaced it earlier. Run this table against your own last five incidents.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Symptom as first observed<\/th><th>Actual cause<\/th><th>Metric that would have caught it earlier<\/th><th>Typical lead time gained<\/th><\/tr><\/thead><tbody><tr><td>Users report OTPs not arriving; dashboard is green<\/td><td>DLR pipeline dead, messages stuck pending, delivery rate computed with the wrong denominator<\/td><td>Pending rate over submitted<\/td><td>30 to 90 minutes<\/td><\/tr><tr><td>Monthly invoice far above forecast<\/td><td>A template gained a curly apostrophe and every message became three UNICODE segments<\/td><td>Encoding label on the submitted units counter shifting TEXT to UNICODE<\/td><td>Days to weeks<\/td><\/tr><tr><td>Support tickets about duplicate messages<\/td><td>A transport-level retry policy applied to the send path<\/td><td>Ambiguous-outcome rate paired with submitted units rising against a flat message count<\/td><td>Hours<\/td><\/tr><tr><td>One customer segment silently receiving nothing<\/td><td>DLT template edited, scrubbing rejecting everything on that template<\/td><td>Failure ratio broken down by <code>dltTemplateId<\/code><\/td><td>Minutes to hours<\/td><\/tr><tr><td>Marketing campaign takes eleven hours instead of two<\/td><td>Worker stalled on a blocked event loop; process alive, health check green<\/td><td>Oldest item age, or drain rate collapsing against flat arrival rate<\/td><td>Hours<\/td><\/tr><tr><td>Sends start failing with an unfamiliar code<\/td><td>Account balance exhausted mid-campaign<\/td><td>Balance runway projection from the dashboard endpoint<\/td><td>12 to 48 hours<\/td><\/tr><tr><td>Delivery numbers stop updating; no errors<\/td><td>Webhook receiver returning 200 while discarding a form-encoded body a JSON parser could not read<\/td><td>Seconds since last receipt gauge<\/td><td>Minutes<\/td><\/tr><tr><td>Reconciliation task cannot resolve old records<\/td><td>Backlog outgrew the delivery report window<\/td><td>Oldest unreconciled record age<\/td><td>Days<\/td><\/tr><tr><td>Throughput looks great, complaints rise<\/td><td>Platform rejecting fast rather than processing slowly<\/td><td>Drain rate paired with success ratio on the same panel<\/td><td>Immediate<\/td><\/tr><tr><td>Delivery rate slowly declining over weeks<\/td><td>Contact list decay, growing share of invalid or unreachable numbers<\/td><td>Cause breakdown trend, absent subscriber share rising<\/td><td>Weeks<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The pattern across every row is the same: the symptom was observed by a human, and a metric existed that could have observed it first. That is the only definition of observability maturity that means anything operationally.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"how-to-instrument-a-messaging-pipeline\" class=\"wp-block-heading\">How to Instrument a Messaging Pipeline in Ten Steps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 1. Write down your message classes before you write any code.<\/strong> Three to six of them. Every metric, SLO, alert and dashboard panel in this article is labelled by class, so getting this taxonomy wrong makes everything downstream unfixable without a migration. Classes are defined by their failure tolerance, not by which team owns them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 2. Instrument the send path with a latency histogram and a units counter.<\/strong> Buckets chosen around your timeout, not defaulted. The counter increments by recipients times segments. Catch connect timeout and read timeout separately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 3. Add explicit outcome classification.<\/strong> Every attempt resolves to exactly one of accepted, rejected, ambiguous or connect-failed. No exception escapes unclassified. Verify with a test that forces each branch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 4. Build the oldest item age gauge.<\/strong> One query, exported per class, returning zero rather than null for an empty queue. This is the highest value-per-line-of-code item in the whole list.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 5. Build the DLR consumer and record delivery outcomes.<\/strong> Poll <code>SMSApi\/reports\/status<\/code> following the cursor, or receive webhooks, or both. Record status, cause, cost and the submit-to-delivery gap. Bounded labels only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 6. Compute delivery rate against submitted.<\/strong> Not against delivered plus failed. Add the invariant assertion that the four outcome counts sum to submitted, and alert when it does not hold.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 7. Add reconciliation metrics.<\/strong> Backlog size, oldest unreconciled age, and resolution breakdown including <code>not_found_assumed_lost<\/code> as its own category. Alert on age, not size.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 8. Add the nightly contract diff and the balance projection.<\/strong> Fetch both error code lists, fail the job on an unhandled code, and default unknown codes to permanent. Fetch the dashboard endpoint, cross-check daily counts against your own, and project balance runway against your procurement lead time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 9. Convert every alert to a ratio against a baseline.<\/strong> The only permitted absolute threshold is oldest item age, because its unit is user patience. Compare against the same hour seven days ago, not against a constant.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 10. Run the incident review table against your last five incidents.<\/strong> For each one, name the metric that would have caught it. If the metric does not exist, build it. If it existed and did not alert, fix the threshold. This step is what turns the previous nine from a configuration exercise into an operational practice, and it is the one most teams skip.<\/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<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Your situation<\/th><th>Instrument this first<\/th><th>Skip for now<\/th><th>Reasoning<\/th><\/tr><\/thead><tbody><tr><td>Low volume, single message class, no queue<\/td><td>Submit outcome classification and delivery rate over submitted<\/td><td>Queue metrics, cardinality management<\/td><td>With no queue there is no oldest item age. Get the denominator right instead<\/td><\/tr><tr><td>OTP is your primary traffic<\/td><td>Oldest item age and enqueue-to-delivery latency histogram<\/td><td>Cost per delivered message<\/td><td>Latency is the product. Cost is secondary at OTP volumes<\/td><\/tr><tr><td>High-volume marketing, cost sensitive<\/td><td>Cost per delivered message and the encoding label<\/td><td>Sub-minute latency panels<\/td><td>Nobody is waiting. Segment inflation is where the money goes<\/td><\/tr><tr><td>Mixed traffic, shared queue<\/td><td>Message class labels everywhere, then per-class SLOs<\/td><td>Anything global<\/td><td>A global aggregate will be dominated by marketing volume and describe no real user<\/td><\/tr><tr><td>India, DLT, many templates<\/td><td>Failure ratio by <code>dltTemplateId<\/code>, plus CI validation<\/td><td>International network breakdowns<\/td><td>Scrubbing failures are the dominant failure mode and are near-invisible in aggregate<\/td><\/tr><tr><td>Webhook-based DLR<\/td><td>Seconds since last receipt gauge, before anything else<\/td><td>Polling metrics<\/td><td>A silent receiver is the failure mode, and silence is invisible by definition<\/td><\/tr><tr><td>Polling-based DLR<\/td><td>Poller lag and cursor progress<\/td><td>Webhook metrics<\/td><td>Your risk is falling behind, not going silent<\/td><\/tr><tr><td>Multi-channel, SMS plus WhatsApp<\/td><td>A normalised outcome model across both, then channel-labelled metrics<\/td><td>Channel-specific dashboards<\/td><td>The response shapes differ. Normalise once at the boundary or every panel needs special cases<\/td><\/tr><tr><td>Already have generic APM, no messaging metrics<\/td><td>Delivery-clock metrics only<\/td><td>Re-instrumenting the HTTP layer<\/td><td>Your APM already covers submission. Delivery is the whole gap<\/td><\/tr><tr><td>Post-incident, need something today<\/td><td>Oldest item age and pending rate over submitted<\/td><td>Everything else<\/td><td>These two catch the majority of incidents and take an afternoon<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"implementation-checklist\" class=\"wp-block-heading\">Implementation Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Message taxonomy<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Message classes defined by failure tolerance, three to six of them<\/li>\n\n\n\n<li>[ ] Every outbound record carries its class at write time<\/li>\n\n\n\n<li>[ ] Delivery report rows joined back to class via <code>msgId<\/code> or <code>uuId<\/code>, with a bounded lookup cache<\/li>\n\n\n\n<li>[ ] Lookup misses labelled <code>unknown<\/code> rather than dropped<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Send path<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Latency recorded as a histogram with buckets straddling the timeout<\/li>\n\n\n\n<li>[ ] Volume counter increments by recipients times segments<\/li>\n\n\n\n<li>[ ] Encoding recorded as a label, TEXT versus UNICODE<\/li>\n\n\n\n<li>[ ] Connect timeout and read timeout classified separately<\/li>\n\n\n\n<li>[ ] Ambiguous outcomes counted in a dedicated metric with its own alert<\/li>\n\n\n\n<li>[ ] <code>invalidMobile<\/code> parsed and counted as rejected at submission, excluded from the delivery denominator<\/li>\n\n\n\n<li>[ ] <code>statusCode<\/code> coerced to string at the boundary<\/li>\n\n\n\n<li>[ ] No automatic transport-level retry configured on the send path<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Queue<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Oldest item age exported per class, returning zero for an empty queue<\/li>\n\n\n\n<li>[ ] Arrival rate and drain rate exported per class<\/li>\n\n\n\n<li>[ ] Sustained-inversion alert configured with a window longer than your longest campaign spike<\/li>\n\n\n\n<li>[ ] Drain rate panel paired with a success ratio panel<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Delivery<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Delivery report consumed by polling with cursor, or by webhook, or both<\/li>\n\n\n\n<li>[ ] Delivery rate computed as delivered over submitted<\/li>\n\n\n\n<li>[ ] Pending rate exported and alerted on<\/li>\n\n\n\n<li>[ ] Invariant asserted: delivered plus failed plus pending plus rejected equals submitted<\/li>\n\n\n\n<li>[ ] Submit-to-delivery gap recorded as a histogram with a tail beyond ten minutes<\/li>\n\n\n\n<li>[ ] Negative gaps discarded rather than observed<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cost<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] <code>cost<\/code> field parsed with a decimal type, never a float<\/li>\n\n\n\n<li>[ ] Cost per delivered message graphed against the rate card as a reference line<\/li>\n\n\n\n<li>[ ] Encoding-shift alert configured per sender ID<\/li>\n\n\n\n<li>[ ] Balance runway projected from the dashboard endpoint and alerted against procurement lead time<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reconciliation<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Unknown-state backlog size exported<\/li>\n\n\n\n<li>[ ] Oldest unreconciled record age exported and alerted, threshold inside the report retention window<\/li>\n\n\n\n<li>[ ] <code>not_found_assumed_lost<\/code> counted separately from <code>found_failed<\/code><\/li>\n\n\n\n<li>[ ] Reconciliation sweep duration recorded<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Error handling<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Nightly diff against <code>SMSApi\/info\/responsecodes<\/code> and <code>SMSApi\/info\/deliverycodes<\/code><\/li>\n\n\n\n<li>[ ] Unknown codes default to permanent, never transient<\/li>\n\n\n\n<li>[ ] Cause breakdown graphed as a stacked area, not a single total<\/li>\n\n\n\n<li>[ ] Failure ratio by <code>dltTemplateId<\/code> on its own panel, with a bounded template allowlist<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Webhook receiver, if used<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Seconds since last receipt gauge exported and alerted<\/li>\n\n\n\n<li>[ ] Raw body persisted before parsing<\/li>\n\n\n\n<li>[ ] Handler latency histogram in the low milliseconds<\/li>\n\n\n\n<li>[ ] Unmatched callbacks counted separately<\/li>\n\n\n\n<li>[ ] Payload schema captured from a real callback and stored as a fixture<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cardinality and privacy<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] No mobile number, transaction ID, message ID or content in any metric label<\/li>\n\n\n\n<li>[ ] Label combinations multiplied out and checked before any new label is added<\/li>\n\n\n\n<li>[ ] Per-metric series limit configured where the backend supports it<\/li>\n\n\n\n<li>[ ] Recipient numbers salted and hashed in logs, never truncated<\/li>\n\n\n\n<li>[ ] Credential redaction implemented as a serialisation filter, not call-site discipline<\/li>\n\n\n\n<li>[ ] Message bodies never logged at full fidelity<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Alerting and review<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Every alert expressed as a ratio, except oldest item age<\/li>\n\n\n\n<li>[ ] Baselines compared against the same hour seven days ago<\/li>\n\n\n\n<li>[ ] Dashboard ordered by detection value, not by volume<\/li>\n\n\n\n<li>[ ] Campaign launches annotated on the dashboard<\/li>\n\n\n\n<li>[ ] Incident review table run against the last five incidents<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten Mistakes That Keep Messaging Incidents Invisible<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. Computing delivery rate as delivered over delivered plus failed.<\/strong> Freezes at a healthy-looking constant during the exact failure where receipts stop arriving. Divide by submitted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. Graphing queue depth with a static threshold.<\/strong> The same number means opposite things during a campaign and during quiet hours. Use oldest item age.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. Counting requests instead of billable units.<\/strong> One request can be thirty thousand units. A request counter is not a volume metric and definitely not a cost metric.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. Averaging latency.<\/strong> Messaging latency distributions are strongly bimodal, so the mean sits in a valley where almost no real message lives. Use a histogram and read percentiles.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. Putting the mobile number in a metric label.<\/strong> One campaign, two hundred thousand new time series, and your monitoring falls over during the event you most need to watch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>6. Catching all timeouts in one branch.<\/strong> Connect timeout means definitely not billed. Read timeout means possibly billed. Collapsing them discards the most consequential distinction in the system.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>7. Never measuring the reconciliation backlog.<\/strong> Unknown records accumulate past the delivery report window and become permanently unresolvable, which converts a data problem into unexplained spend.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>8. Alerting on absolute counts.<\/strong> Fires on every campaign, gets silenced within two weeks, and is silent during the real incident.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>9. Not instrumenting the webhook receiver.<\/strong> A receiver returning 200 while discarding payloads is invisible from both sides. The seconds-since-last-receipt gauge is three lines and catches it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>10. One global SLO across all message classes.<\/strong> Dominated by marketing volume, describes no real user, and makes an OTP outage arithmetically undetectable.<\/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 the single most valuable metric for a messaging pipeline?<\/strong> Oldest item age, exported per message class. It encodes queue depth, drain rate and stalls in one number, and its unit is seconds of user waiting, which makes threshold-setting a product decision rather than a guess. It is also the only metric here where an absolute threshold is appropriate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why is queue depth a bad metric on its own?<\/strong> Because the same value carries opposite meanings depending on context. A depth of 200,000 immediately after a campaign launch is normal. A depth of 400 on a normally empty transactional queue is an incident. Any threshold you set will either miss the second or alarm constantly on the first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How should I compute delivery rate?<\/strong> Delivered divided by submitted. Never delivered divided by delivered plus failed, because that excludes pending and therefore reports healthy numbers during the failure where delivery receipts stop arriving entirely. Also assert that delivered plus failed plus pending plus rejected equals submitted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is an ambiguous outcome and why does it need its own metric?<\/strong> A send where a read timeout occurred after the request was written, so you cannot tell whether the platform accepted and billed it. It needs its own metric because its resolution has a price: retrying may double-bill and double-deliver, while not retrying may deliver nothing. Track both the rate and the age of the oldest unreconciled record.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Which SMSGatewayCenter endpoint gives me delivery data?<\/strong> <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, plus <code>fromdate<\/code>, <code>todate<\/code> and <code>pageLimit<\/code>. It returns a <code>reports_dlrList<\/code> array containing <code>status<\/code>, <code>cause<\/code>, <code>globalErrorCode<\/code>, <code>cost<\/code>, <code>submitTime<\/code>, <code>deliveryTime<\/code>, <code>msgId<\/code>, <code>uuId<\/code>, <code>mobileNo<\/code>, <code>network<\/code> and more. It is cursor-paced via <code>startCursor<\/code> and <code>cursorId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there a consolidated analytics endpoint?<\/strong> Yes. <code>https:\/\/unify.smsgateway.center\/SMSApi\/dashboard\/read<\/code> accepts <code>startDate<\/code> and <code>endDate<\/code> with a documented 31-day maximum range and returns <code>topBar<\/code>, <code>sentSummary<\/code>, <code>donutInfo<\/code> and <code>chartColors<\/code>. Use it as a daily cross-check against your own aggregation and as the source for balance runway alerting, not as your primary metrics source.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why do the daily rows and the donut breakdown disagree?<\/strong> Because they count different status sets. Each <code>sentSummary<\/code> daily row carries submitted, delivered, failed and pending, with no rejected field, while <code>donutInfo<\/code> reports five labels including Rejected. Summing the daily rows and comparing to the donut will diverge whenever anything was rejected. That is expected, not a bug in your code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I catch a message that suddenly costs three times more?<\/strong> Record encoding as a metric label on your volume counter and alert when a sender ID that has historically been TEXT starts producing UNICODE. A single curly apostrophe, en dash, ellipsis character or rupee sign substituted into a template forces the whole body to UCS-2 at 70 characters per segment instead of 160.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use polling or webhooks for delivery receipts?<\/strong> Both, if you can. Webhooks give you low latency but fail silently when your receiver breaks. Polling is slower but self-healing and safe to retry because it is a read endpoint. If you run webhooks alone, the seconds-since-last-receipt gauge is mandatory, because a dead receiver produces no error anywhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What labels are safe on messaging metrics?<\/strong> Channel, message class, sender ID, status, cause, encoding and network are all bounded and safe. Country is borderline when combined with others. Mobile number, transaction ID, message ID and message content are never acceptable, because each produces one time series per message and will take your metrics backend down during a campaign.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I set an SLO target?<\/strong> From your own measured baseline over a representative period, set slightly tighter than the baseline so it can detect regression. This article deliberately quotes no numeric target, because delivery percentages and latencies depend on your routes, destinations, traffic mix and commercial arrangement. A number copied from a benchmark article is a decoration, not an SLO.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does OTP get shed rather than buffered under overload?<\/strong> Because a late OTP is a failed OTP. A user who waits more than roughly thirty seconds presses resend, which costs a second message, and the superseded first code can then cause a failed verification and a support ticket. Failing fast costs one message and produces a cleaner user experience.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I detect a DLT template scrubbing failure quickly?<\/strong> Graph failure ratio broken down by <code>dltTemplateId<\/code>, which appears on every delivery report row. Scrubbing failures are near-binary and sender-scoped: one template goes from zero to total failure within a minute. On an aggregate error graph, one template out of two hundred failing completely might move the global rate by half a percent and be invisible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I load-test my pacing without delivering messages?<\/strong> The send endpoint accepts <code>testMessage<\/code>, documented as enabling a test where messages will not be delivered. What is not documented is whether credits are consumed, whether a delivery receipt is generated, or whether the response body differs. Determine those three empirically on your own account by checking balance before and after and polling the report for that window, and record the result in your runbook before relying on it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-this-article-deliberately-does-not-claim\" class=\"wp-block-heading\">What This Article Deliberately Does Not Claim<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Stating gaps explicitly is more useful than filling them with plausible numbers.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>No numeric TPS ceiling.<\/strong> No throughput limit for the HTTP API is published. Any figure here would be invented. Discover your own ceiling empirically with an adaptive controller rather than coding against a guess.<\/li>\n\n\n\n<li><strong>No numeric SLO or delivery percentage target.<\/strong> These depend on routes, destinations, traffic mix and commercial terms. Derive them from your own baseline.<\/li>\n\n\n\n<li><strong>No webhook push payload schema.<\/strong> The outbound callback field names are not publicly documented. Capture a real callback and store it as a fixture rather than assuming field names.<\/li>\n\n\n\n<li><strong>No claim about <code>testMessage<\/code> credit consumption or DLR generation.<\/strong> The documentation states only that messages will not be delivered. The other behaviours are undocumented and must be determined per account.<\/li>\n\n\n\n<li><strong>No rate limits stated for the reporting or dashboard endpoints.<\/strong> Poll at a conservative interval and back off on any error, rather than assuming a budget.<\/li>\n\n\n\n<li><strong>No claim about delivery report retention window length.<\/strong> Set your reconciliation alert threshold conservatively inside whatever window you observe on your own account, and verify it.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\"><\/h2>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Instrument your messaging pipeline against real endpoints.<\/strong> Every metric in this guide is built from documented SMSGatewayCenter APIs: <code>SMSApi\/send<\/code> for submission, <code>SMSApi\/reports\/status<\/code> for the delivery funnel, <code>SMSApi\/dashboard\/read<\/code> for cross-checking, and the two live error code lists for contract validation. Start with a <a href=\"https:\/\/unify.smsgateway.center\/signup\/\" target=\"_blank\" rel=\"noopener\">free demo account<\/a>, pull the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS Postman collection<\/a> to see every response shape before you write a parser, and browse the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-messaging-api\/\">full API reference<\/a>. If you are running enough volume that an undetected delivery drop is expensive, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to the team<\/a> about routes and reporting for your traffic mix.<\/p>\n<\/blockquote>\n","protected":false},"excerpt":{"rendered":"<p>Most messaging dashboards measure the wrong clock. Billing fires at submission and delivery arrives minutes later, so your submission panel can be entirely green while delivery quietly collapses. This guide covers the four leading indicators that actually predict an incident, runnable Prometheus and OpenTelemetry instrumentation in Python and Node.js, how to build a real delivery funnel from the SMSGatewayCenter Delivery Report and Dashboard Analytics APIs, ratio-based alerting, per-message-class SLOs, and an incident review table mapping each symptom to the metric that would have caught it hours earlier.<\/p>\n","protected":false},"author":118,"featured_media":2707,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2137,2135,2098,404,2133,2134,2132,2136,481,1680,1822,2131],"class_list":["post-2706","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-alerting","tag-delivery-report-api","tag-dlr-reconciliation","tag-dlt","tag-messaging-observability","tag-messaging-slo","tag-opentelemetry","tag-prometheus","tag-sms-api","tag-sms-gateway-monitoring","tag-sms-metrics","tag-sms-monitoring"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2706","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=2706"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2706\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2707"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2706"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2706"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2706"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}