{"id":2953,"date":"2026-09-09T10:15:32","date_gmt":"2026-09-09T04:45:32","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2953"},"modified":"2026-09-09T10:23:07","modified_gmt":"2026-09-09T04:53:07","slug":"delivery-report-ingestion-system-of-record","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/","title":{"rendered":"Delivery Report Ingestion: Building a System of Record, Not a Dashboard"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">For a group send or a file upload send, the delivery report feed is the only place a recipient ever appears. That makes DLR ingestion an audit trail, not telemetry. Here is how to build one that survives a missed push, a provider outage and a compliance question two years later.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/delivery-report-ingestion-system-of-record.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/delivery-report-ingestion-system-of-record-1024x584.webp\" alt=\"Two parallel data streams, one with gaps and one continuous, converging through a single gate into a stacked ledger of immutable delivery records.\" class=\"wp-image-2954\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/delivery-report-ingestion-system-of-record-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/delivery-report-ingestion-system-of-record-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/delivery-report-ingestion-system-of-record-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/delivery-report-ingestion-system-of-record.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">A push feed alone has gaps. A pull backfill closes them. Both write to one append-only record.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#feed-is-evidence\">Why the Delivery Feed Is Evidence, Not Telemetry<\/a><\/li>\n\n\n\n<li><a href=\"#three-endpoints\">The Three Endpoints That Carry Delivery Data<\/a><\/li>\n\n\n\n<li><a href=\"#one-webhook\">One Webhook Per Account, and What That Forces<\/a><\/li>\n\n\n\n<li><a href=\"#webhook-family\">The Webhook CRUD Family Side by Side<\/a><\/li>\n\n\n\n<li><a href=\"#reading-a-row\">Reading a getDlr Row Correctly<\/a><\/li>\n\n\n\n<li><a href=\"#dedupe-key\">Choosing a Dedupe Key That Is Actually Unique<\/a><\/li>\n\n\n\n<li><a href=\"#append-only-table\">The Append-Only Table and Its Projection<\/a><\/li>\n\n\n\n<li><a href=\"#paging\">Paging Through getDlr Without Losing Rows<\/a><\/li>\n\n\n\n<li><a href=\"#backfilling\">Backfilling After an Outage<\/a><\/li>\n\n\n\n<li><a href=\"#reconciling\">Reconciling a Group Send Against a Frozen Audience<\/a><\/li>\n\n\n\n<li><a href=\"#retention\">Retention: Design for the Audit, Not the Dashboard<\/a><\/li>\n\n\n\n<li><a href=\"#zipped-day-report\">Where the Zipped Day Report Fits<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes That Corrupt a Delivery Record<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix: Push Only, Pull Only, or Both<\/a><\/li>\n\n\n\n<li><a href=\"#build-order\">The Build Order<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">The Ingestion Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Treat the delivery report feed as the durable record of who you actually messaged, because on a group send or a file upload send it is the only such record you will ever get. Ingest it two ways: register a webhook so status arrives in seconds, and run a scheduled <code>getDlr<\/code> poll over the same date range so anything the push dropped gets picked up anyway. Write both into one append-only table with a unique constraint on the composite of <code>uuId<\/code>, <code>mobileNo<\/code>, <code>submitTime<\/code> and <code>status<\/code>, because no single field in the payload is a reliable row identifier. Derive your dashboard from that table rather than writing status into it directly. Size retention against your longest audit or dispute window, not against how far back your charts scroll.<\/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>A quick send response returns <code>mobile<\/code> and <code>invalidMobile<\/code>. A group send response returns neither. A file upload send response returns no recipient information at all. For those two send methods the delivery feed is the first and only place a recipient number appears in anything you can store.<\/li>\n\n\n\n<li>There is exactly one SMS webhook per account. <code>webhook\/create<\/code>, <code>webhook\/read<\/code>, <code>webhook\/update<\/code> and <code>webhook\/delete<\/code> carry no webhook identifier of any kind, and <code>webhook\/read<\/code> returns a list of one. You cannot fan out to staging and production simultaneously.<\/li>\n\n\n\n<li>Because there is one URL, a redeploy that repoints it silently blackholes every receipt destined for the environment you moved away from. The pull path is what makes that survivable.<\/li>\n\n\n\n<li><code>msgId<\/code> is not a provider message identifier. In the documented sample it is the literal value <code>orderCreate<\/code>, repeated across two different transactions. Do not build a unique index on it.<\/li>\n\n\n\n<li>The <code>getDlr<\/code> envelope breaks the platform&#8217;s usual shape. <code>count<\/code> is an object with <code>total<\/code> and <code>current<\/code> rather than a scalar, and <code>reports_dlrList<\/code> is a flat array rather than the double-wrapped <code>[i].contact<\/code> style used elsewhere.<\/li>\n\n\n\n<li>One row mixes four JSON types: <code>amount<\/code> is an unquoted float, <code>cost<\/code> an unquoted integer, <code>flashMsg<\/code> a real boolean, and <code>code<\/code> a quoted string. <code>mobileNo<\/code> arrives as an unquoted number.<\/li>\n\n\n\n<li>In the documented sample, <code>deliveryTime<\/code> is 389 milliseconds <em>earlier<\/em> than <code>submitTime<\/code> on the same row. Clamp any latency calculation at zero.<\/li>\n\n\n\n<li><code>cause<\/code> is human readable prose. <code>globalErrorCode<\/code> is the numeric field. Branch on the number.<\/li>\n\n\n\n<li><code>status<\/code> means two different things at two levels. <code>response.status<\/code> tells you the HTTP call worked. Each row&#8217;s <code>status<\/code> tells you whether that message arrived. A perfectly successful call full of <code>FAILED<\/code> rows is the normal case, not an error.<\/li>\n\n\n\n<li>Store the feed append-only and rebuild the current-status view from it. A projection you can rebuild is worth more than a table you have been mutating in place for two years.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"feed-is-evidence\" class=\"wp-block-heading\">Why the Delivery Feed Is Evidence, Not Telemetry<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most teams wire up delivery reports to make a chart go green. That framing quietly decides several things for you: you keep thirty days of data because that is what the chart shows, you overwrite each message&#8217;s row as new status arrives because the chart only cares about the latest value, and you treat a missed webhook as a cosmetic gap because the chart is nearly right anyway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then somebody asks which of the 48,000 people on a promotional list actually received the message on 14 March, and whether a specific customer who says they never consented was included. If your only record is a mutated current-status table with a thirty day window, you cannot answer either question.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason this bites harder here than on a generic webhook integration is structural. Look at what the send call gives you back, keyed on the <code>sendMethod<\/code> you used. A <code>quick<\/code> send returns the recipient list you passed, including <code>mobile<\/code> and <code>invalidMobile<\/code>, so you already know who was accepted. A <code>group<\/code> send returns <code>{\"status\":\"success\",\"group\":\"63\",\"transactionId\":\"8359251506264886974\",\"statusCode\":\"200\",\"reason\":\"success\"}<\/code> and nothing else. There is no <code>mobile<\/code> field and no <code>invalidMobile<\/code> field. A <code>bulkupload<\/code> send returns even less: a status, a transaction id and a reason, with no count of accepted rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So for two of the three send methods, the moment you press send, you have a transaction id and a promise. The people behind it exist only inside the provider until the delivery feed hands them back to you one row at a time. That is not monitoring data. That is the primary record, and everything about how you store it should change accordingly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same logic applies with more force once campaign splitting is enabled on the account, because a single logical send becomes several transactions spread over hours, and the feed is what stitches them back together. It is worth reading <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">the outbound message table schema design guide<\/a> alongside this page, since the table described there is what these rows attach to.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-endpoints\" class=\"wp-block-heading\">The Three Endpoints That Carry Delivery Data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three documented surfaces carry delivery information, and they are not interchangeable.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-two-ingestion-paths.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"546\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-two-ingestion-paths-1024x546.webp\" alt=\"The two delivery report ingestion paths and the single durable table they both write to, plus a third zipped archive path used only for reconciliation.\" class=\"wp-image-2956\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-two-ingestion-paths-1024x546.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-two-ingestion-paths-300x160.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-two-ingestion-paths-768x410.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-two-ingestion-paths.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">The push path is fast and lossy. The pull path is slow and complete. Both write to the same append-only table with an ON CONFLICT clause, so running them together is safe.<\/figcaption><\/figure>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Surface<\/th><th>Endpoint<\/th><th>Direction<\/th><th>What it is good for<\/th><th>What it cannot do<\/th><\/tr><\/thead><tbody><tr><td>Webhook push<\/td><td><code>SMSApi\/webhook\/create<\/code> registers the URL; the provider POSTs to you<\/td><td>Push<\/td><td>Seconds-fresh status, user-facing updates, fast failure alerting<\/td><td>Cannot be replayed. If your endpoint was down, those receipts are simply not re-delivered by any documented mechanism<\/td><\/tr><tr><td>Polling feed<\/td><td><code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code><\/td><td>Pull<\/td><td>Complete row-level data over a date range, cursor paged, filterable by <code>mobileNo<\/code> or <code>uuId<\/code><\/td><td>Not real time, and you must page it yourself<\/td><\/tr><tr><td>Zipped day report<\/td><td><code>SMSApi\/report\/day<\/code><\/td><td>Pull<\/td><td>A whole day in one compressed file, useful as an independent cross-check<\/td><td>One day per call, and no response schema is published, so you cannot write a parser against a contract<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">There is also <code>SMSApi\/reports\/smsSummary<\/code>, which returns aggregate counters grouped by summary, sender id or date. Use it to verify that your ingested totals match the provider&#8217;s totals. Never use it as a source, because it has no recipient granularity at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical arrangement is: webhook for latency, <code>getDlr<\/code> for completeness, <code>smsSummary<\/code> for a cheap daily assertion that the two agree, and <code>report\/day<\/code> when you need a second opinion from a different code path inside the provider.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"one-webhook\" class=\"wp-block-heading\">One Webhook Per Account, and What That Forces<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Read the four webhook endpoints as a family and one thing stands out immediately. None of them accepts a webhook identifier.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>webhook\/create<\/code> takes <code>smswebhook<\/code> (the URL) and <code>smswebhookrate<\/code> (the forwarding TPS, default 10). <code>webhook\/update<\/code> takes exactly the same two parameters. <code>webhook\/delete<\/code> takes nothing but your credentials. And <code>webhook\/read<\/code> takes nothing but your credentials and returns this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"api\": \"webhook\",\n        \"action\": \"read\",\n        \"status\": \"success\",\n        \"msg\": \"success\",\n        \"code\": \"200\",\n        \"count\": 1,\n        \"webhookList\": &#91;\n            {\n                \"webhook\": {\n                    \"smsWebhook\": \"http:\/\/localhost\",\n                    \"smsWebhookRate\": \"50\"\n                }\n            }\n        ]\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One account, one SMS webhook. <code>webhook\/create<\/code> and <code>webhook\/update<\/code> are functionally the same operation, both full replacements of the single slot. <code>webhook\/delete<\/code> empties the slot with no way to say which one you meant, because there is only one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three consequences follow, and they are the difference between a setup that works on Tuesday and one that works in production.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your environments cannot both receive pushes.<\/strong> If staging and production share one account, whichever URL was registered last wins and the other environment goes silent. Either use separate accounts per environment, or accept that only production receives pushes and let non-production environments rely entirely on the pull path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A deploy that repoints the URL creates a gap, not an error.<\/strong> Nothing fails. Receipts simply arrive at the old address until DNS and the registration catch up. The gap is invisible from your side unless you are also polling.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Any credential holder can silence the entire feed with one parameterless call.<\/strong> <code>webhook\/delete<\/code> needs no identifier and no confirmation. Treat the credentials used for webhook management as more privileged than the ones used for sending, and consider reading the registration back on a schedule so an unexpected change is noticed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note the casing flip between request and response, which catches people writing a read-modify-write cycle: you <em>send<\/em> <code>smswebhook<\/code> and <code>smswebhookrate<\/code> in lowercase, and you <em>receive<\/em> <code>smsWebhook<\/code> and <code>smsWebhookRate<\/code> in camelCase. <code>smsWebhookRate<\/code> also comes back as a quoted string even though you sent it as a number. If you read the current config and write it straight back, normalise both directions through one small adapter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The registration itself is worth doing through the API rather than the portal even if you only ever do it once, because an API call is something you can put in version control and assert against in a test. The portal walkthrough is covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/setup-webhook-sms-whatsapp-dlr\/\">the webhook setup knowledge base entry<\/a>, and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">the real-time DLR webhooks article<\/a> covers what the push path is for at a product level.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"webhook-family\" class=\"wp-block-heading\">The Webhook CRUD Family Side by Side<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Success codes vary between endpoint families on this platform, so it is worth checking rather than assuming. For the webhook family they are consistent.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Method<\/th><th>Parameters beyond auth and <code>output<\/code><\/th><th>Success <code>code<\/code><\/th><th>Success <code>msg<\/code><\/th><\/tr><\/thead><tbody><tr><td><code>SMSApi\/webhook\/create<\/code><\/td><td>POST only<\/td><td><code>smswebhook<\/code>, <code>smswebhookrate<\/code><\/td><td><code>\"200\"<\/code><\/td><td><code>Webhook added successfully.<\/code><\/td><\/tr><tr><td><code>SMSApi\/webhook\/read<\/code><\/td><td>POST or GET<\/td><td>none<\/td><td><code>\"200\"<\/code><\/td><td><code>success<\/code><\/td><\/tr><tr><td><code>SMSApi\/webhook\/update<\/code><\/td><td>POST or GET<\/td><td><code>smswebhook<\/code>, <code>smswebhookrate<\/code><\/td><td><code>\"200\"<\/code><\/td><td><code>Webhook updated successfully.<\/code><\/td><\/tr><tr><td><code>SMSApi\/webhook\/delete<\/code><\/td><td>POST or GET<\/td><td>none<\/td><td><code>\"200\"<\/code><\/td><td><code>Webhook deleted successfully.<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">All four return <code>\"200\"<\/code>. That is convenient but it is not a platform-wide guarantee, and writing <code>if (code === \"200\")<\/code> as your success test is a habit that will eventually cost you. At least one endpoint elsewhere in the API returns a different code on success. The check that always holds is: confirm <code>response.api<\/code> and <code>response.action<\/code> match what you called, then test <code>response.status === \"success\"<\/code>. Log <code>code<\/code>, branch on <code>status<\/code>, and never parse <code>msg<\/code>, which is prose and changes without notice.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def assert_ok(payload, expect_api, expect_action):\n    r = payload.get(\"response\") or {}\n    if r.get(\"api\") != expect_api or r.get(\"action\") != expect_action:\n        raise ProtocolError(f\"echo mismatch: got {r.get('api')}\/{r.get('action')}\")\n    if r.get(\"status\") != \"success\":\n        raise ApiError(r.get(\"code\"), r.get(\"msg\"))\n    return r<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That function is four lines and it is correct across every endpoint on this API, including the ones you have not read yet.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reading-a-row\" class=\"wp-block-heading\">Reading a getDlr Row Correctly<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a real documented row from <code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"country\": \"IN\",\n    \"amount\": 0.125,\n    \"msgType\": \"text\",\n    \"cost\": 1,\n    \"deliveryTime\": 1783887217000,\n    \"length\": 13,\n    \"channel\": \"API\",\n    \"msgId\": \"orderCreate\",\n    \"cause\": \"Template Mismatch\",\n    \"mobileNo\": 9177709xxxxx,\n    \"uuId\": \"6358884679059916409\",\n    \"dltTemplateId\": \"12011591428xxxxxxx\",\n    \"globalErrorCode\": 17,\n    \"cursorId\": 162438749,\n    \"network\": \"Idea\",\n    \"senderName\": \"SMSGAT\",\n    \"flashMsg\": false,\n    \"submitTime\": 1783887217389,\n    \"text\": \"test template\",\n    \"status\": \"FAILED\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Twenty fields, four JSON types, and several fields whose names suggest something different from what they contain.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-row-field-traps.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"597\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-row-field-traps-1024x597.webp\" alt=\"Field level parsing traps in a single getDlr row, separating fields safe to key on from fields that look like identifiers but are not.\" class=\"wp-image-2955\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-row-field-traps-1024x597.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-row-field-traps-300x175.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-row-field-traps-768x448.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/09\/diagram-dlr-row-field-traps.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">uuId, mobileNo and submitTime are the durable parts of a row. msgId and cursorId are not identifiers despite their names.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>msgId<\/code> is not a message id.<\/strong> In the documented sample it holds <code>orderCreate<\/code>, and the very next row in the same response holds <code>orderCreate<\/code> again for a completely different <code>uuId<\/code>. It behaves as a free-form label. A unique index on <code>msgId<\/code> will reject legitimate rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>cursorId<\/code> is a paging position.<\/strong> It increments across the scan (162438749, then 162438750) and it is meaningful inside one pagination run. Nothing states that it is stable if you re-run the same query tomorrow, so do not persist it as a foreign key.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>uuId<\/code> is the transaction id.<\/strong> It is the same value the send call returned as <code>transactionId<\/code>, eighteen or nineteen digits, quoted. Store it as TEXT. Nineteen digits exceeds the safe integer range in JavaScript, so <code>JSON.parse<\/code> will silently corrupt it if your language treats unquoted numbers eagerly. It is quoted here, which protects you, but the same identifier appears unquoted in other contexts on this platform, so keep the TEXT column either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>mobileNo<\/code> arrives unquoted.<\/strong> It is a bare JSON number. A twelve-digit Indian number in international format fits inside a double without loss, so you will not see corruption, but the type is wrong and any strict schema validator expecting a string will reject the row. Coerce to string at the boundary and be done with it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>amount<\/code> is per part, <code>cost<\/code> is the part count.<\/strong> <code>amount<\/code> is 0.125 as an unquoted float and <code>cost<\/code> is 1 as an unquoted integer. Multiply them to get what this row cost you. Store money in a fixed-precision column, <code>NUMERIC(12,4)<\/code> in Postgres, never a float, because per-message rates in India are quoted in fractions of a rupee and summing floats across a million rows drifts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The two timestamps do not behave as their names promise.<\/strong> Both are unquoted millisecond epochs with no timezone stated anywhere. On the documented row, <code>deliveryTime<\/code> is 1783887217000 and <code>submitTime<\/code> is 1783887217389, which puts the delivery stamp 389 milliseconds <em>before<\/em> submission. That is a rounding artifact somewhere upstream, and it tells you something useful: <code>deliveryTime<\/code> is better understood as &#8220;when this row reached its final state&#8221; than as &#8220;when the handset received it&#8221;, and any latency metric you compute must clamp at zero rather than record a negative. This row is also <code>FAILED<\/code>, and it still carries a non-zero <code>deliveryTime<\/code>, which confirms the field is a terminal-state stamp rather than a delivery-specific one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>cause<\/code> is prose, <code>globalErrorCode<\/code> is the contract.<\/strong> <code>Template Mismatch<\/code> is a display string. <code>17<\/code> is what you branch on. Route unknown codes to a review queue rather than assuming they are transient, for the reason covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">the retry strategy article<\/a>: defaulting an unknown failure to retryable is how you build a billing loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>status<\/code> is overloaded across levels.<\/strong> The envelope carries <code>\"status\": \"success\"<\/code>, meaning the API call worked. Each row carries its own <code>status<\/code>, here <code>FAILED<\/code>. These are unrelated. A call that returns 200 with two hundred <code>FAILED<\/code> rows is a healthy call reporting bad news, and code that conflates the two will either alert on nothing or alert on everything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The envelope itself is also shaped differently from the rest of this API:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"response\": {\n        \"msg\": \"success\",\n        \"cursors\": { \"next\": \"9ae9e5e\", \"start\": \"9ae9e5d\" },\n        \"code\": \"200\",\n        \"pages\": { \"current\": 1, \"last\": 12 },\n        \"count\": { \"total\": 23, \"current\": 2 },\n        \"reports_dlrList\": &#91; ... ],\n        \"action\": \"status\",\n        \"api\": \"reports\",\n        \"status\": \"success\"\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>count<\/code> is an object here, with <code>total<\/code> and <code>current<\/code>. Elsewhere on this platform <code>count<\/code> is a scalar. And <code>reports_dlrList<\/code> is a flat array of row objects, not the double-wrapped <code>[{\"contact\": {...}}]<\/code> pattern used by the contact and group endpoints. Write the parser for this endpoint specifically. Do not reuse a generic list unwrapper.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"dedupe-key\" class=\"wp-block-heading\">Choosing a Dedupe Key That Is Actually Unique<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Both ingestion paths will hand you the same row, and the push path may hand you the same row more than once. You need a key that makes a repeat insert a no-op.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Work through the candidates:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Candidate<\/th><th>Why it fails<\/th><th>Verdict<\/th><\/tr><\/thead><tbody><tr><td><code>msgId<\/code><\/td><td>Documented sample shows the same value on two unrelated rows<\/td><td>Unusable<\/td><\/tr><tr><td><code>cursorId<\/code><\/td><td>Paging position, not documented as stable across scans<\/td><td>Unusable<\/td><\/tr><tr><td><code>uuId<\/code> alone<\/td><td>One transaction covers many recipients on a multi-recipient send<\/td><td>Too coarse<\/td><\/tr><tr><td><code>uuId<\/code> + <code>mobileNo<\/code><\/td><td>Correct per recipient, but a message can transition through states<\/td><td>Nearly right<\/td><\/tr><tr><td><code>uuId<\/code> + <code>mobileNo<\/code> + <code>submitTime<\/code> + <code>status<\/code><\/td><td>Distinguishes state transitions while collapsing genuine repeats<\/td><td>Use this<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Including <code>status<\/code> in the key is deliberate. If you exclude it, a row arriving as <code>PENDING<\/code> and later as <code>DELIVERED<\/code> collides and you lose the transition. If you include it, both rows land and your table records the sequence, which is exactly what an audit trail should do. Including <code>submitTime<\/code> guards against the same recipient appearing in two separate sends that happen to share a transaction id boundary.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE delivery_event (\n    id            BIGSERIAL PRIMARY KEY,\n    uu_id         TEXT        NOT NULL,\n    mobile_no     TEXT        NOT NULL,\n    submit_time   TIMESTAMPTZ NOT NULL,\n    status        TEXT        NOT NULL,\n    delivery_time TIMESTAMPTZ,\n    cause         TEXT,\n    global_error_code INTEGER,\n    sender_name   TEXT,\n    network       TEXT,\n    country       TEXT,\n    msg_type      TEXT,\n    msg_label     TEXT,\n    dlt_template_id TEXT,\n    parts         INTEGER,\n    amount_per_part NUMERIC(12,4),\n    body_length   INTEGER,\n    flash_msg     BOOLEAN,\n    source        TEXT        NOT NULL,\n    raw           JSONB       NOT NULL,\n    ingested_at   TIMESTAMPTZ NOT NULL DEFAULT now(),\n    CONSTRAINT delivery_event_natural_key\n        UNIQUE (uu_id, mobile_no, submit_time, status)\n);\n\nCREATE INDEX delivery_event_uuid_idx      ON delivery_event (uu_id);\nCREATE INDEX delivery_event_mobile_idx    ON delivery_event (mobile_no, submit_time DESC);\nCREATE INDEX delivery_event_submit_idx    ON delivery_event (submit_time);\nCREATE INDEX delivery_event_failed_idx    ON delivery_event (global_error_code)\n    WHERE global_error_code IS NOT NULL;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two columns there earn their keep beyond the obvious. <code>source<\/code> records whether this row came from the push or the pull, which is how you later measure what fraction of receipts the push path is actually delivering. <code>raw<\/code> keeps the untouched JSON, which is what saves you when a field you did not map turns out to matter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>msg_label<\/code> rather than <code>msg_id<\/code> as the column name. Naming it after what it contains rather than after the wire field stops the next engineer from joining on it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"append-only-table\" class=\"wp-block-heading\">The Append-Only Table and Its Projection<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The instinct is to keep one row per message and update its status as reports arrive. Resist it. An update destroys the previous value, and the previous value is frequently the interesting one, especially when somebody is asking why a message was reported delivered on Monday and disputed on Friday.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Instead, insert every observation and derive current state:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE VIEW current_delivery_status AS\nSELECT DISTINCT ON (uu_id, mobile_no)\n       uu_id,\n       mobile_no,\n       status,\n       cause,\n       global_error_code,\n       delivery_time,\n       submit_time\nFROM   delivery_event\nORDER  BY uu_id, mobile_no, submit_time DESC, ingested_at DESC;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For anything above modest volume, materialise it and refresh on a schedule rather than querying the view directly. The point is not the view mechanism. The point is that the current-status table is <em>derived<\/em> and therefore disposable: if you discover in six months that your status normalisation was wrong, you rebuild the projection from the event table and the fix is complete. If you had been mutating rows in place, the original data is gone and no fix is possible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Dashboards read the projection. Audits read the event table. That split is the whole design.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ingest itself is an upsert that ignores conflicts:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>INSERT_SQL = \"\"\"\nINSERT INTO delivery_event (\n    uu_id, mobile_no, submit_time, status, delivery_time,\n    cause, global_error_code, sender_name, network, country,\n    msg_type, msg_label, dlt_template_id, parts, amount_per_part,\n    body_length, flash_msg, source, raw\n) VALUES (\n    %(uu_id)s, %(mobile_no)s, %(submit_time)s, %(status)s, %(delivery_time)s,\n    %(cause)s, %(global_error_code)s, %(sender_name)s, %(network)s, %(country)s,\n    %(msg_type)s, %(msg_label)s, %(dlt_template_id)s, %(parts)s, %(amount_per_part)s,\n    %(body_length)s, %(flash_msg)s, %(source)s, %(raw)s\n)\nON CONFLICT ON CONSTRAINT delivery_event_natural_key DO NOTHING\n\"\"\"\n\ndef to_row(d, source):\n    return {\n        \"uu_id\":            str(d&#91;\"uuId\"]),\n        \"mobile_no\":        str(d&#91;\"mobileNo\"]),\n        \"submit_time\":      ms_to_dt(d&#91;\"submitTime\"]),\n        \"status\":           (d.get(\"status\") or \"UNKNOWN\").upper(),\n        \"delivery_time\":    ms_to_dt(d.get(\"deliveryTime\")),\n        \"cause\":            d.get(\"cause\"),\n        \"global_error_code\": as_int(d.get(\"globalErrorCode\")),\n        \"sender_name\":      d.get(\"senderName\"),\n        \"network\":          d.get(\"network\"),\n        \"country\":          d.get(\"country\"),\n        \"msg_type\":         d.get(\"msgType\"),\n        \"msg_label\":        d.get(\"msgId\"),\n        \"dlt_template_id\":  none_if_sentinel(d.get(\"dltTemplateId\")),\n        \"parts\":            as_int(d.get(\"cost\")),\n        \"amount_per_part\":  as_decimal(d.get(\"amount\")),\n        \"body_length\":      as_int(d.get(\"length\")),\n        \"flash_msg\":        bool(d.get(\"flashMsg\")),\n        \"source\":           source,\n        \"raw\":              Json(d),\n    }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>none_if_sentinel<\/code> matters more than it looks. This platform uses <code>\"0\"<\/code> and empty string as &#8220;not applicable&#8221; in several places, and letting those land in your columns as real values is how a report ends up counting a template id of zero as a distinct template. Normalise sentinels once, at the boundary, in one function that every ingestion path calls.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ms_to_dt<\/code> should parse into UTC and store as <code>TIMESTAMPTZ<\/code>. No timezone is stated for these epochs, but a millisecond epoch is conventionally UTC-anchored, and storing with an explicit zone means that if you later learn the offset is different, you correct one conversion function and re-derive rather than reinterpreting every stored value.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"paging\" class=\"wp-block-heading\">Paging Through getDlr Without Losing Rows<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>getDlr<\/code> gives you two paging mechanisms in the same envelope: a cursor pair under <code>cursors<\/code> and a page counter under <code>pages<\/code>. Use the cursor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Page numbers over a live dataset are unreliable in the ordinary way. If rows land while you are scanning, page 3 on your second call is not the page 3 you would have got on your first, and rows shift across the boundary. The <code>cursors.next<\/code> value is a position, and feeding it back as <code>startCursor<\/code> continues from where you stopped rather than from a recomputed offset.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def fetch_dlr(session, creds, fromdate, todate, page_limit=500):\n    cursor = None\n    while True:\n        params = {\n            **creds,\n            \"method\":    \"getDlr\",\n            \"fromdate\":  fromdate,\n            \"todate\":    todate,\n            \"pageLimit\": page_limit,\n            \"output\":    \"json\",\n        }\n        if cursor:\n            params&#91;\"startCursor\"] = cursor\n\n        resp = session.post(\n            \"https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status\",\n            data=params,\n            timeout=(5, 60),\n        )\n        resp.raise_for_status()\n        body = resp.json()&#91;\"response\"]\n\n        if body.get(\"status\") != \"success\":\n            raise ApiError(body.get(\"code\"), body.get(\"msg\"))\n\n        rows = body.get(\"reports_dlrList\") or &#91;]\n        if not rows:\n            return\n\n        yield from rows\n\n        cursors = body.get(\"cursors\") or {}\n        nxt = cursors.get(\"next\")\n        if not nxt or nxt == cursor:\n            return\n        cursor = nxt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four details in there are deliberate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The loop terminates on three separate conditions: an empty row list, a missing <code>next<\/code>, and a <code>next<\/code> identical to the cursor you just used. Any one of them alone leaves a way to spin forever, and an infinite loop against a reporting endpoint is a good way to get rate limited.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>reports_dlrList<\/code> is fetched with <code>or []<\/code> because an empty result set may omit the key entirely rather than returning an empty array. That pattern recurs across this API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The timeout is a tuple, five seconds to connect and sixty to read. A reporting query over a wide date range is legitimately slow, and the default in most HTTP clients is either nothing at all or too short. This is a read-only endpoint, so unlike the send path, retrying it is free and safe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pageLimit<\/code> at 500 is a starting point, not a documented ceiling. Start there, watch your response times, and adjust. If you see timeouts, lower it before you lower the date range, because narrower pages over the same range is cheaper than more calls over narrower ranges.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"backfilling\" class=\"wp-block-heading\">Backfilling After an Outage<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your webhook endpoint was down for four hours. What now?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing in the documented interface re-delivers a missed push. There is no replay endpoint, no dead letter queue you can drain, and no acknowledgement protocol that would tell the provider you did not get it. That is precisely why the pull path is not optional.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The backfill is a <code>getDlr<\/code> scan over the affected window, written through the same ingest function with <code>source<\/code> set to <code>backfill<\/code>. Because the unique constraint absorbs everything you already have, you can be generous with the window. Overlap it by a wide margin on both ends.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def backfill(session, creds, start_date, end_date):\n    inserted = 0\n    seen = 0\n    for row in fetch_dlr(session, creds, start_date, end_date, page_limit=500):\n        seen += 1\n        inserted += insert_event(to_row(row, source=\"backfill\"))\n    return seen, inserted<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The gap between <code>seen<\/code> and <code>inserted<\/code> is the useful number. If you scan a normal, uninterrupted day and 2 percent of rows insert, your push path is healthy and the pull is confirming it. If 40 percent insert, your webhook is dropping receipts and you have just discovered it before an auditor did.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That ratio deserves to be a permanent metric, not a one-off diagnostic. Run the backfill nightly over the previous two days regardless of whether anything broke, and alert on the insert ratio crossing a threshold. It is the only signal that tells you the push path is degrading, because a degrading push path produces no errors at all. This complements the leading indicators covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">the observability guide<\/a>, which deals with what to watch on the submission side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One caution on window width. <code>fromdate<\/code> and <code>todate<\/code> are dates, not timestamps, so the smallest window you can request is a full day. A four hour outage means re-scanning at least one whole day, and if it straddled midnight, two. Budget for that.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reconciling\" class=\"wp-block-heading\">Reconciling a Group Send Against a Frozen Audience<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is where the audit value becomes concrete.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You sent to group 63. The send response told you <code>\"group\": \"63\"<\/code> and gave you a transaction id. It did not tell you who was in the group. Group membership is resolved on the provider side at send time, and if somebody edited that group afterwards, reading it back now gives you a different answer than the one the send used.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So freeze the audience yourself, at submission time, before you send:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE campaign_audience (\n    uu_id        TEXT NOT NULL,\n    mobile_no    TEXT NOT NULL,\n    contact_ref  TEXT,\n    frozen_at    TIMESTAMPTZ NOT NULL DEFAULT now(),\n    PRIMARY KEY (uu_id, mobile_no)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read the group&#8217;s members through <code>contact\/read<\/code>, filter to the group you are about to send to, write that snapshot keyed on the transaction id you get back, and only then consider the send recorded. Yes, <code>contact\/read<\/code> returns your whole address book with no filter, so this is a full read. Do it anyway, or cache it with a short TTL and accept the staleness risk explicitly. The snapshot is the thing that makes the next query possible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then reconciliation is a three-way count:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>WITH latest AS (\n    SELECT DISTINCT ON (uu_id, mobile_no)\n           uu_id, mobile_no, status\n    FROM   delivery_event\n    WHERE  uu_id = %(uu_id)s\n    ORDER  BY uu_id, mobile_no, submit_time DESC, ingested_at DESC\n)\nSELECT\n    (SELECT count(*) FROM campaign_audience WHERE uu_id = %(uu_id)s) AS frozen,\n    (SELECT count(*) FROM latest)                                    AS reported,\n    (SELECT count(*) FROM latest WHERE status = 'DELIVERED')         AS delivered,\n    (SELECT count(*)\n       FROM campaign_audience a\n      WHERE a.uu_id = %(uu_id)s\n        AND NOT EXISTS (SELECT 1 FROM latest l\n                         WHERE l.mobile_no = a.mobile_no))           AS never_reported;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four numbers, and each discrepancy means something specific.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>reported<\/code> below <code>frozen<\/code> means either the feed has not caught up yet, or those recipients were dropped before submission. Deduplication is one legitimate reason: within-request duplicate removal is on by default, so if the same number appeared twice in your frozen audience, it correctly produces one row. Overlapping groups in a single send are the usual source of that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>reported<\/code> above <code>frozen<\/code> means your snapshot was taken from a different membership than the one the send used, which tells you the freeze happened too late or the group was edited between read and send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>never_reported<\/code> staying above zero for more than a day is your real alarm. Those are people your system believes it messaged and for whom no evidence exists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rows in the feed that are not in your snapshot at all deserve immediate investigation, because they are recipients you cannot account for and, in a consent dispute, cannot defend.<\/p>\n\n\n\n<h2 id=\"retention\" class=\"wp-block-heading\">Retention: Design for the Audit, Not the Dashboard<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two different clocks govern how long you keep this data, and the shorter one is almost never the binding constraint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The dashboard clock is how far back your charts scroll. Ninety days is generous for that. The audit clock is how long after a send somebody might ask you to prove what happened. In India, commercial messaging sits under the TRAI framework on unsolicited commercial communication, where consent and complaint handling create an obligation to produce records well after the fact. Personal data handling adds its own layer under the national data protection framework. Neither of those windows is ninety days.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Set retention from the audit clock and let the dashboard read a shorter slice of the same table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A workable arrangement:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Tier<\/th><th>Contents<\/th><th>Retention<\/th><th>Storage<\/th><\/tr><\/thead><tbody><tr><td>Hot<\/td><td><code>delivery_event<\/code> rows for the current quarter plus the projection<\/td><td>Rolling 120 days<\/td><td>Primary database, fully indexed<\/td><\/tr><tr><td>Warm<\/td><td>Older <code>delivery_event<\/code> rows, partitioned by month<\/td><td>To the end of your audit window<\/td><td>Same database, detached partitions or compressed table<\/td><\/tr><tr><td>Cold<\/td><td>Monthly exports plus the corresponding zipped day reports<\/td><td>Longest applicable obligation<\/td><td>Object storage with versioning and access logging<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to get right regardless of the tiering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Message bodies are the expensive part, in both storage and risk.<\/strong> The <code>text<\/code> field carries the rendered message, which for a transactional send contains personal data: names, amounts, order references, sometimes one-time codes. If you do not need the body after some window, drop that column on the warm tier and keep the metadata. Dropping one column beats deleting whole rows, because the row is the evidence and the body is usually not the part being disputed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A deletion request has to reach this table.<\/strong> If somebody exercises a data deletion right, <code>delivery_event<\/code> holds their phone number and possibly their name inside <code>text<\/code> and <code>raw<\/code>. It is an easy table to forget precisely because it feels like logs. Put it in the same inventory as your customer tables, and decide in advance which parts are subject to deletion and which are retained under a legal obligation, because those are different answers and you want to have made the call before you are asked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Partition by <code>submit_time<\/code> from day one. Dropping a partition is instant; deleting fifty million rows by date range on a table with five indexes is an outage.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"zipped-day-report\" class=\"wp-block-heading\">Where the Zipped Day Report Fits<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SMSApi\/report\/day<\/code> takes your credentials and a single <code>date<\/code> in <code>YYYY-MM-DD<\/code> and returns that day&#8217;s report compressed. One day per call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Its value is that it comes from a different code path than the polling API. When your ingested totals and the provider&#8217;s totals disagree, having a third independent view is what turns a stalemate into a diagnosis. Pull it monthly, keep the file untouched in cold storage next to your own export, and you have a provider-generated artifact you did not construct, which is a materially stronger position in a dispute than a report you generated from your own database.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use it for that, and only for that. It is unsuited to being your primary feed for reasons that are structural rather than incidental: one call per day means a year of history is 365 calls, there is no cursor or filter, and no response schema is published, so any parser you write is inferred from an example rather than a contract. Write the parser defensively, log the raw file, and keep the file itself rather than only your parse of it, so that a parser fix can be applied retroactively.<\/p>\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 Corrupt a Delivery Record<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Updating a status row in place.<\/strong> The prior value is often the one being asked about. Insert, then derive.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Keying on <code>msgId<\/code>.<\/strong> It repeats across unrelated rows in the documented sample. Your unique index will reject valid data or your join will fan out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Treating <code>response.status<\/code> as the message status.<\/strong> A successful call reporting two hundred failures is normal. Read the row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Parsing <code>cause<\/code> to classify failures.<\/strong> It is prose. <code>globalErrorCode<\/code> is the number, and it does not change wording between releases.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Computing latency as <code>deliveryTime<\/code> minus <code>submitTime<\/code> without a floor.<\/strong> The documented sample produces a negative value. Clamp at zero or you will publish a chart with impossible numbers on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Running only the push path.<\/strong> A missed push is not re-delivered, produces no error, and is undetectable without a pull to compare against.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Storing <code>uuId<\/code> as a bigint.<\/strong> Nineteen digits, and in JavaScript that is past the safe integer boundary. TEXT.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Storing <code>amount<\/code> as a float and summing it.<\/strong> Fixed precision, <code>NUMERIC(12,4)<\/code>. The drift is small per row and material per million.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Letting sentinel values into typed columns.<\/strong> Empty strings and <code>\"0\"<\/code> mean &#8220;not applicable&#8221; in several places on this platform. Normalise them to NULL at the boundary, once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Sizing retention from the dashboard.<\/strong> The chart needs ninety days. The dispute arrives in month eighteen.<\/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: Push Only, Pull Only, or Both<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Dimension<\/th><th>Push only<\/th><th>Pull only<\/th><th>Both<\/th><\/tr><\/thead><tbody><tr><td>Status freshness<\/td><td>Seconds<\/td><td>As frequent as you schedule<\/td><td>Seconds<\/td><\/tr><tr><td>Completeness after an outage<\/td><td>Rows are lost with no replay<\/td><td>Complete on the next run<\/td><td>Complete<\/td><\/tr><tr><td>Detects its own failures<\/td><td>No, silence is indistinguishable from no traffic<\/td><td>Yes, a failed job errors<\/td><td>Yes, insert ratio is the signal<\/td><\/tr><tr><td>Infrastructure required<\/td><td>A public HTTPS endpoint with high availability<\/td><td>A scheduled worker, no inbound exposure<\/td><td>Both<\/td><\/tr><tr><td>Works in staging<\/td><td>No, one URL per account<\/td><td>Yes<\/td><td>Partially<\/td><\/tr><tr><td>Load pattern<\/td><td>Continuous, shaped by <code>smswebhookrate<\/code><\/td><td>Bursty at scan time<\/td><td>Both<\/td><\/tr><tr><td>Backpressure control<\/td><td>Adjust <code>smswebhookrate<\/code><\/td><td>Adjust <code>pageLimit<\/code> and frequency<\/td><td>Both levers<\/td><\/tr><tr><td>Handles late-arriving status<\/td><td>Yes if the push fires<\/td><td>Yes if inside the scan window<\/td><td>Yes<\/td><\/tr><tr><td>Cost of a bug in your handler<\/td><td>Data lost permanently<\/td><td>Re-run the scan<\/td><td>Re-run the scan<\/td><\/tr><tr><td>Suitable for user-facing status<\/td><td>Yes<\/td><td>Only with a short interval<\/td><td>Yes<\/td><\/tr><tr><td>Suitable as audit evidence<\/td><td>Not on its own<\/td><td>Yes<\/td><td>Yes<\/td><\/tr><tr><td>Recommended<\/td><td>For latency only, never alone<\/td><td>For low-volume or batch-oriented systems<\/td><td>Default for anything with a compliance dimension<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If you have to choose one, choose pull. A slow complete record beats a fast incomplete one when somebody asks a question about last March. Push is what you add to make the record fast as well as complete.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"build-order\" class=\"wp-block-heading\">The Build Order<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step one. Create <code>delivery_event<\/code> with the composite unique constraint, partitioned by <code>submit_time<\/code>.<\/strong> Get the constraint right before any data lands, because retrofitting a unique index onto a table that already contains duplicates is a migration you do not want.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step two. Write one normalisation function and one insert function.<\/strong> Every path calls them. Sentinel handling, type coercion and timestamp conversion live in exactly one place.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step three. Build the pull path first.<\/strong> It needs no public endpoint, no TLS certificate and no inbound firewall rule, so you can have it working today. Run it over a past date range and watch real rows land.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step four. Add the projection and point your dashboard at it.<\/strong> Confirm you can rebuild it from scratch and get the same answer. If you cannot, the event table is missing something.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step five. Register the webhook and write the handler.<\/strong> Acknowledge with a 200 immediately, enqueue, and process asynchronously. A handler that does database work inline will eventually exceed whatever timeout the sender applies, and a timeout on the sender&#8217;s side is indistinguishable from your endpoint being down. Start <code>smswebhookrate<\/code> at the default of 10 and raise it only after the handler has proven itself under load.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step six. Turn the backfill into a scheduled job and alert on the insert ratio.<\/strong> This is the step that converts the whole arrangement from an integration into a system of record, because it is the only part that tells you when the fast path has quietly stopped working.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Steps three and five in that order matter. Building the pull path first means that when the webhook handler has a bad week, you already have the safety net rather than building it in a hurry afterwards.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">The Ingestion Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Storage and schema:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>delivery_event<\/code> exists with a unique constraint on <code>(uu_id, mobile_no, submit_time, status)<\/code><\/li>\n\n\n\n<li><code>uu_id<\/code> is TEXT, not a numeric type<\/li>\n\n\n\n<li><code>mobile_no<\/code> is TEXT and numbers are coerced at the boundary<\/li>\n\n\n\n<li><code>amount_per_part<\/code> is <code>NUMERIC(12,4)<\/code> or equivalent fixed precision<\/li>\n\n\n\n<li>Both timestamps stored as <code>TIMESTAMPTZ<\/code>, converted from millisecond epochs into UTC<\/li>\n\n\n\n<li><code>raw<\/code> JSONB column preserves the untouched payload<\/li>\n\n\n\n<li><code>source<\/code> column distinguishes push from pull rows<\/li>\n\n\n\n<li>Table is partitioned by <code>submit_time<\/code><\/li>\n\n\n\n<li>Indexes exist on <code>uu_id<\/code>, on <code>(mobile_no, submit_time)<\/code> and on <code>global_error_code<\/code><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Parsing:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>response.status<\/code> and each row&#8217;s <code>status<\/code> are handled as separate concepts<\/li>\n\n\n\n<li>Success is tested with <code>api<\/code> plus <code>action<\/code> plus <code>status<\/code>, never on <code>code<\/code> alone<\/li>\n\n\n\n<li><code>msg<\/code> is logged, never parsed<\/li>\n\n\n\n<li>Failure classification branches on <code>globalErrorCode<\/code>, not <code>cause<\/code><\/li>\n\n\n\n<li>Unknown error codes route to review rather than defaulting to retryable<\/li>\n\n\n\n<li><code>msgId<\/code> is stored but not used as a key<\/li>\n\n\n\n<li><code>cursorId<\/code> is not persisted as a durable identifier<\/li>\n\n\n\n<li>Sentinel values (<code>\"0\"<\/code>, empty string) normalise to NULL in one shared function<\/li>\n\n\n\n<li><code>reports_dlrList<\/code> is read with a null-safe default<\/li>\n\n\n\n<li>Latency calculations clamp at zero<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Push path:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Registration is done through <code>webhook\/create<\/code> from version-controlled code, not by hand<\/li>\n\n\n\n<li>The registered URL is read back on a schedule and compared against expectations<\/li>\n\n\n\n<li>The handler acknowledges within a second and processes asynchronously<\/li>\n\n\n\n<li>The handler is idempotent and relies on the database constraint rather than an in-memory set<\/li>\n\n\n\n<li>Non-production environments do not share an account with production, or knowingly forgo pushes<\/li>\n\n\n\n<li>Credentials able to call <code>webhook\/delete<\/code> are held more tightly than send credentials<\/li>\n\n\n\n<li><code>smswebhookrate<\/code> is set deliberately and its effect on handler load is measured<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Pull path:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Cursor paging uses <code>cursors.next<\/code>, not <code>pages.current<\/code><\/li>\n\n\n\n<li>The loop terminates on empty rows, missing cursor, and repeated cursor<\/li>\n\n\n\n<li>Connect and read timeouts are both set explicitly<\/li>\n\n\n\n<li>A nightly scan covers at least the previous two days<\/li>\n\n\n\n<li>The insert ratio is recorded as a metric and alerted on<\/li>\n\n\n\n<li>Date-granularity windows are accounted for in outage recovery planning<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Audit and retention:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Audience is frozen at submission time for group and file upload sends<\/li>\n\n\n\n<li>The three-count reconciliation runs per campaign<\/li>\n\n\n\n<li><code>never_reported<\/code> above zero after 24 hours raises an alert<\/li>\n\n\n\n<li>Retention is set from the audit window, not the dashboard window<\/li>\n\n\n\n<li>Message bodies have their own shorter retention than row metadata<\/li>\n\n\n\n<li>The table appears in the data inventory used for deletion requests<\/li>\n\n\n\n<li>Monthly zipped day reports are archived unparsed alongside your own exports<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some behaviours here are not pinned down by anything you can read, and waiting for an answer is not a plan. Each item below gives the choice that stays correct whichever way the behaviour actually resolves, so you can build now and never revisit it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Verify the push payload against a row you already have.<\/strong> The shape the provider POSTs to your webhook may or may not match the <code>getDlr<\/code> row shape exactly. Rather than guessing, register the webhook, send one message to a number you control, capture the raw body verbatim, and compare it field by field against the <code>getDlr<\/code> row for the same <code>uuId<\/code>. Write your handler against what you captured, keep the raw body in the <code>raw<\/code> column from day one, and log a warning on any key you did not expect. You will have a verified contract in an afternoon, and the warning log tells you if it ever drifts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Assume the push is not retried, and let the pull be your retry.<\/strong> Whether a failed POST to your endpoint is re-attempted, how many times, and with what backoff, is not something to discover during an incident. Build as though there is exactly one attempt and no replay. The nightly backfill then makes the question irrelevant, because a receipt that never arrived by push arrives by pull within a day either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Treat every timestamp as UTC and store the offset explicitly.<\/strong> No timezone is stated for <code>submitTime<\/code> or <code>deliveryTime<\/code>. Millisecond epochs are conventionally UTC-anchored, so parse them that way into a timezone-aware column. If the anchor turns out to be different, you correct one conversion function and re-derive from <code>raw<\/code>. If you had stored naive local timestamps, there would be nothing to correct from.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Never compute a negative latency.<\/strong> The documented sample has <code>deliveryTime<\/code> earlier than <code>submitTime<\/code> on the same row, so whatever generates these two stamps does not guarantee ordering. Clamp the difference at zero, and record how often the clamp fires. If that counter is small, it is rounding. If it is large, the two fields are measured against different clocks and your latency metric needs rethinking regardless.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Pin your retention to your own obligations, not the provider&#8217;s window.<\/strong> How far back the reporting endpoints will serve is not published. The safe posture is to hold everything you might need in your own storage, which you control, and to treat provider-side history as a convenience for backfill rather than as your archive. Run your first backfill early, over the widest range that returns data, so you learn the practical reach empirically rather than assuming it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Start <code>pageLimit<\/code> low and tune upward.<\/strong> No maximum is stated. Begin at a few hundred, measure the response time, and raise it while latency stays comfortable. A scan that completes in ten pages of 500 is better than one page of 5,000 that times out and leaves you unsure how much you received.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Rate-limit your own polling.<\/strong> Whether the reporting endpoints enforce a request ceiling is not something you want to find out by tripping it mid-backfill. Put a modest delay between pages, run scans from a single worker rather than in parallel across instances, and use the throttling patterns from <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rate-limiting-backpressure-messaging-systems\/\">the rate limiting and backpressure guide<\/a> on the read path as well as the write path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Treat status values as an open set.<\/strong> <code>DELIVERED<\/code>, <code>FAILED<\/code> and <code>PENDING<\/code> are the ones you will see immediately, but nothing states that the list is closed. Map known values into your own vocabulary, pass anything unrecognised through as-is with a warning, and never let an unknown status crash the ingest or, worse, get silently coerced into a known one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Read your webhook registration back before you rely on it.<\/strong> Because there is one slot and <code>webhook\/update<\/code> overwrites it wholesale, another process or another person can change it without producing any signal on your side. A scheduled <code>webhook\/read<\/code> that compares the returned URL and rate against what you expect turns a silent change into an alert. It costs one call an hour.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Keep the raw payload for every row, forever, or at least as long as the row.<\/strong> Any field you did not map, any type you guessed wrong, any sentinel you failed to normalise is recoverable if you kept the original and unrecoverable if you did not. The JSONB column costs storage. Not having it costs a re-ingestion you cannot perform.<\/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>Do I need both the webhook and the polling API, or can I pick one?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can run either alone, but they fail differently. The webhook gives you seconds-fresh status and loses rows silently if your endpoint is unavailable, with no documented replay. Polling gives you a complete record with a delay set by your schedule. If delivery data feeds anything that must be defensible later, run both and let the poll confirm the push.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I register different webhook URLs for staging and production?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Not within one account. All four webhook endpoints operate on a single account-wide slot and none of them accepts an identifier, so registering a second URL replaces the first. Use separate accounts per environment, or let non-production rely on the polling path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What identifies a single delivery report row uniquely?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No single field does. <code>msgId<\/code> repeats across unrelated rows and <code>cursorId<\/code> is a paging position. Use the composite of <code>uuId<\/code>, <code>mobileNo<\/code>, <code>submitTime<\/code> and <code>status<\/code>, which distinguishes genuine state transitions while collapsing duplicate deliveries of the same observation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why is <code>deliveryTime<\/code> sometimes earlier than <code>submitTime<\/code>?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The two stamps are not guaranteed to be ordered, and the documented sample shows exactly this. Read <code>deliveryTime<\/code> as the moment the row reached its final state rather than as a handset receipt time, and clamp any duration you compute from the pair at zero.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is <code>amount<\/code> the cost of the message or the cost of one part?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is the cost of a single part. Multiply <code>amount<\/code> by <code>cost<\/code>, which holds the part count, to get what that row cost. Store the result in a fixed-precision column rather than a float.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I know who received a group send?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">From the delivery feed. The group send response returns the group identifier and a transaction id and no recipient information at all, so the feed is the first place a recipient number appears in anything you can store. Freeze the audience yourself at submission time so you have something to reconcile against.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens to receipts that arrive while my endpoint is down?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Assume they are gone from the push path. Recover them with a <code>getDlr<\/code> scan over the affected dates. Since <code>fromdate<\/code> and <code>todate<\/code> are dates rather than timestamps, a partial-day outage still means re-scanning at least one full day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I store the message body?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Store it initially, because it is often what a dispute turns on, and give it a shorter retention than the rest of the row. The body contains personal data for most transactional sends, so keeping it as long as the metadata is usually the wrong trade.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use the zipped day report as my main feed?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is better used as an independent cross-check. One call covers one day, there is no filtering or cursor, and no response schema is published, so a parser written against it is inferred rather than contracted. Archive the files unparsed and let the polling API be your ingestion path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How often should the backfill run?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nightly over the previous two days is a reasonable default. The overlap costs almost nothing because the unique constraint absorbs repeats, and the fraction of rows that actually insert becomes your health metric for the push path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is <code>smswebhookrate<\/code> actually doing?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It sets the rate at which delivery reports are forwarded to your endpoint, documented with a default of 10. Treat it as a backpressure control: if your handler is queueing behind incoming pushes, lowering it is a legitimate lever, and raising it only makes sense once the handler is comfortably keeping up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I branch on the <code>code<\/code> field to detect success?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. Confirm that <code>api<\/code> and <code>action<\/code> echo what you called, then test <code>status<\/code>. Codes are not uniform across this API, and at least one endpoint returns something other than 200 on a successful operation, so a <code>code == \"200\"<\/code> test will eventually classify a success as a failure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I detect that my webhook stopped working?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Compare the two paths. Count how many rows a scheduled <code>getDlr<\/code> scan inserts that the push had not already delivered. A healthy push path leaves that number near zero. A rising number is the only early warning you will get, because a webhook that is not firing produces no errors anywhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does deduplication affect my reconciliation counts?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. Within-request duplicate recipient removal is enabled by default, so a number appearing twice in one request produces one delivery row. If your frozen audience contains overlapping groups, expect the reported count to be lower than the frozen count by exactly the number of duplicates, and treat that as correct rather than as missing data.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Delivery data is only as good as what you do with it.<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you are building messaging into a system where the record matters as much as the send, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to the SMSGatewayCenter team<\/a> about your account setup, or start with <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">the developer documentation<\/a> and the SMS Postman collection.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>For a group send or a file upload send, the delivery report feed is the only place a recipient ever appears. That makes DLR ingestion an audit trail, not telemetry. Here is how to build one that survives a missed push, a provider outage and a compliance question two years later.<\/p>\n","protected":false},"author":118,"featured_media":2954,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2226,2227,2016,1994,2228,2106,2221,481,2229,597],"class_list":["post-2953","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-audit-trail","tag-data-retention","tag-delivery-reports","tag-dlr","tag-getdlr","tag-messaging-infrastructure","tag-reconciliation","tag-sms-api","tag-system-of-record","tag-webhooks"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2953","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=2953"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2953\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2954"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2953"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2953"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2953"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}