{"id":2824,"date":"2026-08-28T08:00:34","date_gmt":"2026-08-28T02:30:34","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2824"},"modified":"2026-08-28T08:00:37","modified_gmt":"2026-08-28T02:30:37","slug":"outbound-message-table-schema-design","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/","title":{"rendered":"The Outbound Message Table: Designing the Schema Everything Else Depends On"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Retries, idempotency, delivery reconciliation, cost attribution and every alert you will ever build all read from one table. Here is how to design it correctly the first time, using the real identifier, timestamp and status shapes the SMSGatewayCenter API actually returns.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/outbound-message-table.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"614\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/outbound-message-table-1024x614.webp\" alt=\"Diagram of a central outbound message table with separate submission and delivery receipt paths running on two different clocks.\" class=\"wp-image-2825\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/outbound-message-table-1024x614.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/outbound-message-table-300x180.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/outbound-message-table-768x461.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/outbound-message-table.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Every reliability behaviour in a messaging system reads from and writes to one table. Design it for two clocks, not one.<\/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\">Executive Summary<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#why-this-table-is-different-from-an-outbox\">Why This Table Is Different From an Outbox<\/a><\/li>\n\n\n\n<li><a href=\"#the-minimum-viable-schema\">The Minimum Viable Schema<\/a><\/li>\n\n\n\n<li><a href=\"#three-levels-of-identity-not-one\">Three Levels of Identity, Not One<\/a><\/li>\n\n\n\n<li><a href=\"#store-provider-identifiers-as-text-always\">Store Provider Identifiers as Text, Always<\/a><\/li>\n\n\n\n<li><a href=\"#the-string-zero-is-not-a-value\">The String Zero Is Not a Value<\/a><\/li>\n\n\n\n<li><a href=\"#two-level-key-design-business-key-and-attempt-key\">Two-Level Key Design: Business Key and Attempt Key<\/a><\/li>\n\n\n\n<li><a href=\"#the-state-column-and-its-legal-transitions\">The State Column and Its Legal Transitions<\/a><\/li>\n\n\n\n<li><a href=\"#modelling-the-batch-response-fan-out\">Modelling the Batch Response Fan-Out<\/a><\/li>\n\n\n\n<li><a href=\"#when-there-is-no-provider-identifier-at-all\">When There Is No Provider Identifier At All<\/a><\/li>\n\n\n\n<li><a href=\"#one-table-or-many-the-otp-question\">One Table or Many? The OTP Question<\/a><\/li>\n\n\n\n<li><a href=\"#timestamps-four-different-formats-one-column-type\">Timestamps: Four Different Formats, One Column Type<\/a><\/li>\n\n\n\n<li><a href=\"#the-columns-money-depends-on\">The Columns Money Depends On<\/a><\/li>\n\n\n\n<li><a href=\"#the-columns-observability-depends-on\">The Columns Observability Depends On<\/a><\/li>\n\n\n\n<li><a href=\"#indexing-for-the-four-access-patterns\">Indexing for the Four Access Patterns<\/a><\/li>\n\n\n\n<li><a href=\"#scheduled-and-campaign-sends-are-different-rows\">Scheduled and Campaign Sends Are Different Rows<\/a><\/li>\n\n\n\n<li><a href=\"#partitioning-retention-and-the-report-window\">Partitioning, Retention and the Report Window<\/a><\/li>\n\n\n\n<li><a href=\"#migrating-an-existing-naive-sends-table\">Migrating an Existing Naive Sends Table<\/a><\/li>\n\n\n\n<li><a href=\"#building-the-table-in-ten-steps\">Building the Table in Ten Steps<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#pre-production-checklist\">Pre-Production Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes-that-show-up-later\">Ten Mistakes That Show Up Later<\/a><\/li>\n\n\n\n<li><a href=\"#what-this-article-deliberately-does-not-claim\">What This Article Deliberately Does Not Claim<\/a><\/li>\n\n\n\n<li><a href=\"#frequently-asked-questions\">Frequently Asked Questions<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"executive-summary\" class=\"wp-block-heading\">Executive Summary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you are building anything that sends messages at scale, the single highest-leverage design decision you will make is the shape of one table. Call it <code>outbound_message<\/code>. Retry logic reads it to decide what is safe to send again. Idempotency reads it to decide whether a send already happened. Delivery reconciliation writes to it when receipts arrive minutes or hours later. Cost reporting sums a column in it. Every alert worth having is a query against it. Get it wrong and you do not get a bug, you get a category of bugs that reappear in different disguises for years.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason the standard advice does not work here is that almost all published guidance on this problem is the transactional outbox pattern, and the outbox pattern assumes the destination is a message broker that gives you a synchronous acknowledgement. A messaging provider does not behave like a broker. It gives you a submission receipt on one clock and a delivery receipt on a second, much slower clock, it bills you at submission rather than at delivery, it returns one identifier for a request that may contain ten thousand recipients, and on at least one send path it returns no usable identifier at all. A schema built on the assumption of a single acknowledgement cannot represent any of that, so teams end up bolting on side tables, nullable columns with contradictory meanings, and reconciliation jobs that quietly guess.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This article gives you the full design. It uses the real request and response shapes documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">SMSGatewayCenter developer API<\/a>, including the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">batch send endpoint<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">delivery report endpoint<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-campaign-sms\/\">campaign read endpoint<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">one-time password endpoints<\/a>, because a schema designed against imagined field shapes is a schema you will migrate within a quarter.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The answer in one paragraph:<\/strong> design the outbound message table around one row per recipient per attempt, never one row per API request. Give it two keys: a deterministic <code>business_key<\/code> that your application computes before it calls anything, and a nullable <code>provider_message_id<\/code> that arrives later. Store every provider identifier as <code>TEXT<\/code>, never as an integer type. Model state as a forward-only enum whose critical value is <code>unknown<\/code>, and make the transition from <code>unknown<\/code> back to <code>pending<\/code> structurally impossible. Record two independent timestamps, <code>submitted_at<\/code> and <code>delivered_at<\/code>, because your system runs on two clocks. Store cost as <code>NUMERIC<\/code>, and store billable units as recipients multiplied by segments rather than as a request count, because that is what you are actually charged for. Index for exactly four access patterns: claim the next row, find rows stuck in <code>unknown<\/code>, compute the age of the oldest unresolved row, and correlate by recipient plus sender plus time window for the paths where no provider identifier exists.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-this-table-is-different-from-an-outbox\" class=\"wp-block-heading\">Why This Table Is Different From an Outbox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The transactional outbox is a good pattern and it is well documented. Milan Jovanovic&#8217;s <a href=\"https:\/\/milanjovanovic.tech\/blog\/implementing-the-outbox-pattern\" target=\"_blank\" rel=\"noopener nofollow\">walkthrough of the outbox pattern<\/a> and the <a href=\"https:\/\/event-driven.io\/en\/outbox_inbox_patterns_and_delivery_guarantees_explained\/\" target=\"_blank\" rel=\"noopener nofollow\">Event-Driven.io explanation of outbox, inbox and delivery guarantees<\/a> are both worth reading before you design anything here, because the transactional half of the problem, writing your business change and your intent to send inside the same database transaction, is genuinely solved by them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The part they do not solve is what happens after the relay picks the row up. In a classic outbox, the relay publishes to a broker, the broker acknowledges, the row is marked sent, and the story ends. Five things break that story when the destination is a messaging provider.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>First, submission is not delivery.<\/strong> A successful HTTP response means the provider accepted your message. It does not mean a handset received it. The delivery receipt arrives later, through a completely separate mechanism, either a webhook push or a poll against the reports endpoint. Your row therefore has two independent completions, and a schema with a single <code>processed_at<\/code> column cannot express the state where submission succeeded and delivery failed. That state is where the majority of real messaging incidents live.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Second, you are billed at the earlier of the two events.<\/strong> The published billing terms on the SMSGatewayCenter <a href=\"https:\/\/www.smsgatewaycenter.com\/pricing\/\">pricing pages<\/a> state that credits are non-refundable once an SMS is successfully submitted to the operator. That single sentence has more schema consequences than any other fact in this article. It means a row that reaches submitted has already cost money whether or not it ever reaches delivered, which means the cost column must be populated at submission time and must never be recomputed from delivery state. It also means an over-eager retry is not a correctness problem that you can clean up later, it is a billing event that has already happened. The economics are unpacked in more depth in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">what actually drives bulk SMS cost in India<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Third, one request is many messages.<\/strong> The batch send endpoint accepts a comma-separated recipient list and returns exactly one top-level <code>transactionId<\/code> for the whole request. If you model one row per request, you have no place to record that recipient 4,712 out of 10,000 failed. If you model one row per recipient, you need somewhere to record that all ten thousand of them share a submission identifier.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fourth, some recipients are rejected inside a successful response.<\/strong> The documented sample success response for the batch endpoint includes an <code>invalidMobile<\/code> field alongside a <code>status<\/code> of <code>success<\/code>. Recipients enumerated there were not accepted. A naive parser that branches only on the top-level <code>status<\/code> marks all ten thousand rows submitted and then spends a week wondering why a few hundred never produced a delivery receipt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fifth, at least one send path issues no identifier.<\/strong> On the one-time password endpoints, the documented sample responses return <code>transactionId<\/code> as the literal string <code>\"0\"<\/code> on both generate and verify. There is no per-send correlation key on that path. Any schema whose reconciliation strategy assumes a provider identifier always exists cannot represent OTP traffic at all, and OTP traffic is usually the most business-critical traffic in the system.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Those five facts, not any general database principle, are what the design below is shaped by.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-minimum-viable-schema\" class=\"wp-block-heading\">The Minimum Viable Schema<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start here. Every column below earns its place, and the reasoning for each appears in a later section.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TYPE message_state AS ENUM (\n    'pending',      -- intent recorded, nothing sent\n    'claimed',      -- a worker has taken ownership, send not yet attempted\n    'submitted',    -- provider accepted; BILLABLE FROM HERE\n    'rejected',     -- provider refused before submission; not billable\n    'delivered',    -- delivery receipt confirms handset receipt\n    'failed',       -- delivery receipt confirms permanent failure\n    'unknown',      -- outcome genuinely indeterminate; NEVER retried blind\n    'expired'       -- validity window passed with no receipt\n);\n\nCREATE TYPE message_channel AS ENUM ('sms', 'whatsapp', 'voice', 'rcs', 'telegram');\n\nCREATE TABLE outbound_message (\n    -- identity\n    id                  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    business_key        TEXT        NOT NULL,\n    attempt_key         UUID        NOT NULL DEFAULT gen_random_uuid(),\n\n    -- routing\n    tenant_id           TEXT        NOT NULL,\n    channel             message_channel NOT NULL,\n    message_class       TEXT        NOT NULL,   -- 'otp' | 'transactional' | 'promotional'\n    recipient           TEXT        NOT NULL,   -- E.164, no plus sign, normalised on write\n    sender_id           TEXT,                   -- alphanumeric header, 6 chars in India\n\n    -- content\n    body                TEXT        NOT NULL,\n    encoding            TEXT        NOT NULL,   -- 'GSM7' | 'UCS2'\n    segments            SMALLINT    NOT NULL,\n    dlt_entity_id       TEXT,\n    dlt_template_id     TEXT,\n\n    -- state machine\n    state               message_state NOT NULL DEFAULT 'pending',\n    state_reason        TEXT,\n    attempt_count       SMALLINT    NOT NULL DEFAULT 0,\n\n    -- provider identity, all TEXT, all nullable\n    provider_txn_id     TEXT,                   -- transactionId \/ uuId, one per REQUEST\n    provider_message_id TEXT,                   -- msgId, one per RECIPIENT\n    provider_campaign_id TEXT,                  -- campaignid, one per CAMPAIGN\n    provider_status_code TEXT,\n    provider_cause      TEXT,\n\n    -- two clocks\n    created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),\n    claimed_at          TIMESTAMPTZ,\n    submitted_at        TIMESTAMPTZ,\n    delivered_at        TIMESTAMPTZ,\n    reconciled_at       TIMESTAMPTZ,\n    scheduled_for       TIMESTAMPTZ,\n    expires_at          TIMESTAMPTZ,\n\n    -- money\n    billable_units      SMALLINT,\n    cost                NUMERIC(12,4),\n    cost_currency       CHAR(3),\n\n    -- audit\n    updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One caveat on the primary key as written: if you adopt the monthly partitioning described later, the primary key must include the partition column, so it becomes <code>PRIMARY KEY (id, created_at)<\/code> declared at the table level rather than inline. Decide on partitioning before you create the table, because retrofitting that change is a full rewrite.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The uniqueness constraint is separate and deliberate:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE UNIQUE INDEX uq_outbound_business_key\n    ON outbound_message (tenant_id, business_key);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That constraint is the whole idempotency mechanism. It is discussed at length in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">message idempotency at scale<\/a>, which is the companion article to this one and remains the canonical treatment of duplicate prevention. This article is about the table those techniques require; that one is about the techniques.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"three-levels-of-identity-not-one\" class=\"wp-block-heading\">Three Levels of Identity, Not One<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/three-levels-of-identity-not-one.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"600\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/three-levels-of-identity-not-one.webp\" alt=\"Illustration about Three Levels of Identity, Not One\" class=\"wp-image-2826\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/three-levels-of-identity-not-one.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/three-levels-of-identity-not-one-300x180.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/three-levels-of-identity-not-one-768x461.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Most teams discover this the hard way, usually during an incident, so it is worth stating plainly. The platform models message identity at three levels, and your table needs a column for each.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Level one, the campaign.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-campaign-sms\/\">campaign read endpoint<\/a> at <code>https:\/\/unify.smsgateway.center\/SMSApi\/campaign\/read<\/code> takes a <code>campaignid<\/code> and describes it in the documentation as fetching the details of a <em>split<\/em> campaign. That word matters. A campaign is a container that the platform may divide into more than one transaction. The response returns a <code>campaignList<\/code> array whose elements each wrap a <code>campaign<\/code> object containing <code>uuId<\/code>, <code>status<\/code>, <code>total<\/code>, <code>timestamp<\/code>, <code>scheduledTimestamp<\/code> and <code>lastupdatedTimestamp<\/code>. So a campaign identifier and a transaction identifier are not the same thing, and one campaign can map to several transactions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Level two, the transaction.<\/strong> This is <code>transactionId<\/code> in the send response and <code>uuId<\/code> in the report responses. There is exactly one per API request. Ten thousand recipients in a single call share one value. This is your submission-side correlation key and it is what you pass to the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/check-mis-by-transaction-id\/\">by-transaction report endpoint<\/a> later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Level three, the per-recipient message.<\/strong> This is <code>msgId<\/code>, which appears in the <code>reports_dlrList<\/code> array returned by the delivery report endpoint. This is the only identifier that is one-to-one with a physical message to a handset, and it is the only one you can safely mark unique per recipient row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The mistake this produces is storing the transaction identifier in a column named <code>message_id<\/code> and putting a unique constraint on it. The first batch of more than one recipient blows up the constraint, someone drops the constraint under time pressure, and the table loses its integrity guarantee permanently. Three columns, three meanings, and only <code>provider_message_id<\/code> is ever unique per row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a fourth case, which is the absence of any of these. It is covered below.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"store-provider-identifiers-as-text-always\" class=\"wp-block-heading\">Store Provider Identifiers as Text, Always<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Look at two real documented sample values. The send endpoint returns <code>\"transactionId\":\"6305583318236810379\"<\/code>. The campaign and schedule read endpoints return <code>\"uuId\": \"5718742519829975000\"<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both are nineteen digits. JavaScript&#8217;s <code>Number.MAX_SAFE_INTEGER<\/code> is 9,007,199,254,740,991, which is sixteen digits. Any of these values passed through a plain <code>JSON.parse<\/code> in Node.js, or through a JSON layer in any language that maps JSON numbers to IEEE 754 doubles, is silently corrupted. Note that the campaign sample value ends in three zeros, which is precisely the signature of a value that has already been through a lossy numeric conversion somewhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fortunately the API quotes them, so they arrive as strings. The failure mode is entirely self-inflicted: a developer sees a numeric-looking string, decides <code>BIGINT<\/code> is tidier, and adds a cast. Postgres <code>BIGINT<\/code> tops out around 9.22 x 10^18 so the value technically fits, which makes the bug worse rather than better, because it works in the database and breaks only when a JavaScript service reads the row back and re-serialises it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three rules, no exceptions.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Column type is <code>TEXT<\/code> or <code>VARCHAR<\/code>, never <code>BIGINT<\/code>, <code>NUMERIC<\/code> or <code>INTEGER<\/code>.<\/li>\n\n\n\n<li>Never let a JSON parser coerce these fields. In Node.js use a parser configured to keep large integers as strings, or extract them with a string-preserving path before generic parsing. In Python, <code>json.loads<\/code> preserves them as strings already because they are quoted; the danger there is a well-meaning <code>int()<\/code> cast in a normalisation function.<\/li>\n\n\n\n<li>Compare them with string equality. Never with numeric equality, and never after trimming leading zeros.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The same rule applies to <code>dlt_template_id<\/code>, which is a nineteen-digit-class identifier in practice and is registration-bound, and to <code>dlt_entity_id<\/code>. Treat every externally issued identifier as an opaque string. You never do arithmetic on any of them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-string-zero-is-not-a-value\" class=\"wp-block-heading\">The String Zero Is Not a Value<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the subtlest trap in the whole data model and it appears in at least two places in the documented responses.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On the one-time password path, both the generate and verify sample responses return <code>\"transactionId\":\"0\"<\/code>. That is not a transaction identifier. It is a placeholder meaning no identifier was issued.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On the campaign and schedule read responses, <code>\"lastupdatedTimestamp\": \"0\"<\/code> appears for a record that has never been updated. That is not the Unix epoch of 1 January 1970. It means never.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you store those verbatim you have created two categories of bug at once. Your reconciliation query <code>WHERE provider_txn_id IS NOT NULL<\/code> matches every OTP row and finds nothing. Your dashboard shows a fleet of messages last updated in 1970. Your oldest-item-age alert, which is the best leading indicator any messaging system has, computes an age of fifty-six years and either fires constantly or gets muted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Normalise on write, in exactly one place:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SENTINEL_NULLS = {\"\", \"0\", \"null\", \"NULL\", \"-\"}\n\ndef clean_id(value):\n    \"\"\"Provider identifiers: return None for documented sentinel placeholders.\"\"\"\n    if value is None:\n        return None\n    v = str(value).strip()\n    return None if v in SENTINEL_NULLS else v\n\ndef clean_epoch_ms(value):\n    \"\"\"Millisecond epoch strings: '0' means never, not 1970.\"\"\"\n    if value is None:\n        return None\n    v = str(value).strip()\n    if v in SENTINEL_NULLS:\n        return None\n    return datetime.fromtimestamp(int(v) \/ 1000, tz=timezone.utc)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every provider response passes through those two functions before it touches the table. Not some responses. All of them. The moment a second parsing path exists, one of them will forget.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A useful hardening step is a check constraint that makes the mistake impossible to store:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ALTER TABLE outbound_message\n    ADD CONSTRAINT ck_no_sentinel_ids CHECK (\n        (provider_txn_id     IS NULL OR provider_txn_id     NOT IN ('0','')) AND\n        (provider_message_id IS NULL OR provider_message_id NOT IN ('0','')) AND\n        (provider_campaign_id IS NULL OR provider_campaign_id NOT IN ('0',''))\n    );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Constraints are better than conventions because constraints survive the departure of the person who knew about the convention.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-level-key-design-business-key-and-attempt-key\" class=\"wp-block-heading\">Two-Level Key Design: Business Key and Attempt Key<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The table needs two identifiers of its own, and they answer different questions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The business key answers: should this message exist at all?<\/strong> It is deterministic, computed by your application before any network call, and derived entirely from the business event. A workable composition is tenant, event type, event identifier, normalised recipient and template identifier:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def business_key(tenant, event_type, event_id, recipient, template_id=None):\n    parts = &#91;tenant, event_type, str(event_id), normalise_msisdn(recipient)]\n    if template_id:\n        parts.append(str(template_id))\n    return hashlib.sha256(\"|\".join(parts).encode()).hexdigest()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two properties matter. It is stable across process restarts, redeploys and queue redeliveries, which is what makes <code>INSERT ... ON CONFLICT DO NOTHING<\/code> a real duplicate guard. And it <strong>deliberately excludes the channel<\/strong>. If order 8841&#8217;s shipping notification goes out over WhatsApp and the fallback chain later re-attempts it over SMS, that is the same business message reaching the customer twice. Including the channel in the key would let both through and the customer would receive two notifications for one event. Exclude it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The attempt key answers: which physical send is this?<\/strong> It is a UUID, generated fresh per row, and it is what you carry in logs, traces and structured log correlation. If a business message legitimately produces a second physical send after a permanent failure and a manual replay, that is a second row with the same business key intent but a new attempt key. Whether you allow that at all is a policy decision, and if you do, the unique index on the business key needs a partial predicate excluding superseded rows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE UNIQUE INDEX uq_outbound_business_key_active\n    ON outbound_message (tenant_id, business_key)\n    WHERE state &lt;&gt; 'superseded';<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you add a <code>superseded<\/code> value to the enum for this purpose, add it as a terminal state with no outgoing transitions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note carefully what the business key is <strong>not<\/strong>. It is not the platform&#8217;s <code>duplicatecheck<\/code> parameter. That optional parameter on the batch send endpoint is documented as enabling the removal of duplicate mobile numbers and defaults to true. It performs recipient deduplication <strong>within a single request<\/strong>. There is no cross-request time window and it is not a retry guard. Relying on it as one is a documented mistake that has appeared in the wild, and it is worth being explicit because the parameter name invites the wrong reading.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-state-column-and-its-legal-transitions\" class=\"wp-block-heading\">The State Column and Its Legal Transitions<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-state-column-and-its-legal-transitions.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"600\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-state-column-and-its-legal-transitions.webp\" alt=\"Illustration about The State Column and Its Legal Transitions\" class=\"wp-image-2827\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-state-column-and-its-legal-transitions.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-state-column-and-its-legal-transitions-300x180.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/the-state-column-and-its-legal-transitions-768x461.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">State is a single enum column, not a scatter of booleans. Booleans permit contradictions: <code>is_sent = true<\/code> alongside <code>is_failed = true<\/code> is representable and therefore will eventually be represented. An enum makes it unrepresentable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The transitions are forward only. Here is the complete legal set.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>From<\/th><th>To<\/th><th>Trigger<\/th><th>Billable at this point<\/th><\/tr><\/thead><tbody><tr><td><code>pending<\/code><\/td><td><code>claimed<\/code><\/td><td>Worker takes ownership<\/td><td>No<\/td><\/tr><tr><td><code>pending<\/code><\/td><td><code>expired<\/code><\/td><td>Scheduled window passed unclaimed<\/td><td>No<\/td><\/tr><tr><td><code>claimed<\/code><\/td><td><code>pending<\/code><\/td><td>Worker released the claim before any HTTP call was made<\/td><td>No<\/td><\/tr><tr><td><code>claimed<\/code><\/td><td><code>submitted<\/code><\/td><td>Provider returned a success response<\/td><td><strong>Yes<\/strong><\/td><\/tr><tr><td><code>claimed<\/code><\/td><td><code>rejected<\/code><\/td><td>Provider refused, for example template mismatch or invalid header<\/td><td>No<\/td><\/tr><tr><td><code>claimed<\/code><\/td><td><code>unknown<\/code><\/td><td>Read timeout, connection reset after send, ambiguous response<\/td><td><strong>Assume yes<\/strong><\/td><\/tr><tr><td><code>submitted<\/code><\/td><td><code>delivered<\/code><\/td><td>Delivery receipt reports success<\/td><td>Yes<\/td><\/tr><tr><td><code>submitted<\/code><\/td><td><code>failed<\/code><\/td><td>Delivery receipt reports permanent failure<\/td><td>Yes<\/td><\/tr><tr><td><code>submitted<\/code><\/td><td><code>expired<\/code><\/td><td>Validity window passed with no receipt<\/td><td>Yes<\/td><\/tr><tr><td><code>unknown<\/code><\/td><td><code>submitted<\/code><\/td><td>Reconciliation found a matching record<\/td><td>Yes<\/td><\/tr><tr><td><code>unknown<\/code><\/td><td><code>failed<\/code><\/td><td>Reconciliation proved nothing was submitted<\/td><td>No<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">And here is the transition that does not exist, which is more important than any of the ones that do.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>unknown<\/code> never returns to <code>pending<\/code>.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>claimed<\/code> to <code>pending<\/code> transition in the table above is narrowly permitted, and only when the worker can prove no HTTP request was ever issued, for example a validation error thrown before the client call. If there is any chance a request left the process, the correct destination is <code>unknown<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason is arithmetic. An ambiguous outcome means the request may have been submitted. If it was, it has already been billed. Sending it back to <code>pending<\/code> puts it in the claim queue again, and it will be sent again, and billed again. The correct behaviour is that a row in <code>unknown<\/code> is only ever resolved by <strong>reading<\/strong>, never by writing: a reconciliation sweep queries the delivery report endpoint and moves the row forward based on evidence. Where there is no evidence to be had, as on the OTP path, the row stays <code>unknown<\/code> and a human looks at it. That is the correct outcome. A permanently unresolved row is cheaper than a duplicate send, and infinitely cheaper than a duplicate OTP.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Enforce it in the database, not in a code comment:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE OR REPLACE FUNCTION enforce_message_state_transition()\nRETURNS TRIGGER AS $$\nBEGIN\n    IF OLD.state = 'unknown' AND NEW.state = 'pending' THEN\n        RAISE EXCEPTION\n          'illegal transition unknown -&gt; pending on outbound_message id=%', OLD.id;\n    END IF;\n    IF OLD.state IN ('delivered','failed','expired','rejected')\n       AND NEW.state &lt;&gt; OLD.state THEN\n        RAISE EXCEPTION\n          'terminal state % cannot transition to % on id=%',\n          OLD.state, NEW.state, OLD.id;\n    END IF;\n    NEW.updated_at := now();\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER trg_message_state_transition\n    BEFORE UPDATE ON outbound_message\n    FOR EACH ROW EXECUTE FUNCTION enforce_message_state_transition();<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That trigger has caught more real bugs in review than any test suite, because it fails loudly in staging the first time somebody writes a well-intentioned requeue job.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The relationship between state and retry policy is the subject of <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">the SMS API retry strategy guide<\/a>, which classifies failures into retryable and permanent categories. This table is where that classification is recorded and acted on.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"modelling-the-batch-response-fan-out\" class=\"wp-block-heading\">Modelling the Batch Response Fan-Out<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The batch endpoint returns a single response for many recipients. The documented sample looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"mobile\": \"919999999999\",\n  \"invalidMobile\": \"\",\n  \"transactionId\": \"6305583318236810379\",\n  \"statusCode\": \"200\",\n  \"reason\": \"success\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things in that response drive schema decisions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>transactionId<\/code> is one value for the whole request.<\/strong> Write it to every row in the batch. Do not make it unique. It is your handle for the by-transaction report lookup later, and it is the only correlation you have until per-recipient <code>msgId<\/code> values start arriving in delivery reports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>invalidMobile<\/code> can be non-empty while <code>status<\/code> reads <code>success<\/code>.<\/strong> Those recipients were not accepted. They are <code>rejected<\/code>, not <code>submitted<\/code>, and critically they are <strong>not billable<\/strong>. A parser that keys only on the top-level status inflates your cost attribution and creates a permanent population of rows that will never receive a delivery receipt, which then poisons your pending-message alert.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fan-out therefore looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def apply_batch_response(conn, batch_row_ids, resp):\n    txn_id  = clean_id(resp.get(\"transactionId\"))\n    invalid = {normalise_msisdn(m) for m in\n               (resp.get(\"invalidMobile\") or \"\").split(\",\") if m.strip()}\n\n    now = datetime.now(timezone.utc)\n\n    with conn.transaction():\n        # rejected recipients first, so they can never be counted as billable\n        conn.execute(\"\"\"\n            UPDATE outbound_message\n               SET state = 'rejected',\n                   state_reason = 'invalidMobile',\n                   provider_txn_id = %s,\n                   updated_at = now()\n             WHERE id = ANY(%s) AND recipient = ANY(%s) AND state = 'claimed'\n        \"\"\", (txn_id, batch_row_ids, list(invalid)))\n\n        # everything else in the batch was accepted, and is now billable\n        conn.execute(\"\"\"\n            UPDATE outbound_message\n               SET state = 'submitted',\n                   provider_txn_id = %s,\n                   provider_status_code = %s,\n                   submitted_at = %s,\n                   billable_units = segments,      -- one recipient per row\n                   updated_at = now()\n             WHERE id = ANY(%s) AND state = 'claimed'\n        \"\"\", (txn_id, str(resp.get(\"statusCode\")), now, batch_row_ids))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The ordering is deliberate. Rejections are written first so that a crash between the two statements leaves rejected rows correctly marked rather than optimistically marked submitted. Both statements are inside one transaction anyway, but the ordering documents the intent for the next reader.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note the <code>state = 'claimed'<\/code> predicate on both updates. That makes the fan-out itself idempotent. If the same response is applied twice, perhaps because a worker crashed after the provider call and a reconciliation sweep replayed it, the second application matches zero rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>billable_units<\/code> is set to <code>segments<\/code> here because each row is one recipient. If you ever denormalise to one row per batch, this column becomes recipients multiplied by segments, and the reason that formula matters, rather than a request count, is that it is what determines the number on your invoice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"when-there-is-no-provider-identifier-at-all\" class=\"wp-block-heading\">When There Is No Provider Identifier At All<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The one-time password path returns <code>transactionId<\/code> as the string <code>\"0\"<\/code> on both generate and verify. There is no per-send correlation key. This is not an oversight you can work around by asking for one; it is the shape of the interface, and the schema has to accommodate it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The only correlation available is reconstruction from three facts you already control: the recipient, the sender identifier, and a tight window around your own submission timestamp. Those three fields are all present in the <code>reports_dlrList<\/code> array returned by the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">delivery report endpoint<\/a>, as <code>mobileNo<\/code>, <code>senderName<\/code> and <code>submitTime<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reconstruction has <strong>three<\/strong> possible outcomes, not two, and the schema has to be able to record all three.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TYPE correlation_result AS ENUM ('matched', 'not_found', 'ambiguous');\n\nALTER TABLE outbound_message\n    ADD COLUMN correlation_result correlation_result,\n    ADD COLUMN correlation_window_ms INTEGER;<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>def reconcile_by_reconstruction(row, dlr_records, window_seconds=90):\n    \"\"\"Correlate a row that has no provider identifier.\n\n    Returns one of 'matched', 'not_found', 'ambiguous'.\n    NEVER guesses between two candidates.\n    \"\"\"\n    lo = row.submitted_at - timedelta(seconds=window_seconds)\n    hi = row.submitted_at + timedelta(seconds=window_seconds)\n\n    candidates = &#91;\n        r for r in dlr_records\n        if normalise_msisdn(r&#91;\"mobileNo\"]) == row.recipient\n        and r&#91;\"senderName\"] == row.sender_id\n        and lo &lt;= parse_submit_time(r&#91;\"submitTime\"]) &lt;= hi\n    ]\n\n    if len(candidates) == 1:\n        return \"matched\", candidates&#91;0]\n    if len(candidates) == 0:\n        return \"not_found\", None\n    return \"ambiguous\", None   # two OTPs to one recipient inside the window<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>ambiguous<\/code> branch is not a rare edge case. It is the resend case, and resends are the single most common thing a user does with an OTP. Two codes to the same number ninety seconds apart is normal traffic, not an anomaly. A reconciler that picks the closest candidate when there are two is not reconciling, it is guessing, and it will attribute a delivery failure to the wrong attempt roughly half the time it fires.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The correct handling is to record <code>ambiguous<\/code>, leave the row&#8217;s state as <code>unknown<\/code>, and never retry it. This is the sharpest practical instance of the general rule from the idempotency article: an ambiguous outcome is resolved by evidence or not at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two consequences follow for the OTP path specifically, and both are schema-visible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never batch OTP.<\/strong> A batch shares one transaction identifier, and on the OTP path there is no transaction identifier to share, so batching removes the last shred of correlation. One recipient per request, one row per request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the correlation window tight and store it.<\/strong> <code>correlation_window_ms<\/code> is stored on the row rather than being a global constant because you will tune it, and when you tune it you need to know which rows were reconciled under which window before you trust a historical comparison.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"one-table-or-many-the-otp-question\" class=\"wp-block-heading\">One Table or Many? The OTP Question<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the design question that has no obvious answer, so here is the reasoning rather than just the verdict.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The case for one polymorphic table is that everything in the reliability layer is shared. Business key, state machine, claim-before-send, reconciliation, oldest-item-age, cost attribution and every alert you will build apply identically to an OTP and to a promotional blast. Splitting them means writing all of that twice, and the second copy will drift from the first within two quarters. It also means every cross-channel query becomes a UNION, and the oldest-item-age gauge, which needs a single global answer, becomes a query that somebody will forget to extend when a third table appears.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The case for a separate table is that OTP carries state nothing else carries: a code hash, an expiry, a verification attempt counter, a single-use consumed flag. Those are security-sensitive, they have a much shorter lifetime than the message row, and they justify different access controls and a much more aggressive retention policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The recommended shape is one table plus a sidecar.<\/strong> Keep <code>outbound_message<\/code> polymorphic and complete for everything in the reliability layer. Put the verification state in a narrow adjacent table that references it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE otp_verification (\n    outbound_message_id BIGINT PRIMARY KEY\n        REFERENCES outbound_message(id) ON DELETE CASCADE,\n    code_hash        BYTEA       NOT NULL,   -- never the code itself\n    code_length      SMALLINT    NOT NULL,\n    code_type        TEXT        NOT NULL,   -- 'num' | 'alpha' | 'alphanum'\n    expires_at       TIMESTAMPTZ NOT NULL,\n    retry_after      TIMESTAMPTZ,            -- from the documented retryAfter field\n    verify_count     SMALLINT    NOT NULL DEFAULT 0,\n    consumed_at      TIMESTAMPTZ,\n    purpose          TEXT        NOT NULL,   -- bind the code to what it authorises\n    session_binding  TEXT\n);\n\nCREATE INDEX idx_otp_unconsumed\n    ON otp_verification (expires_at)\n    WHERE consumed_at IS NULL;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This resolves cleanly. Reliability logic reads one table. Security logic reads a narrow, short-lived, separately governed table. You can purge <code>otp_verification<\/code> aggressively without losing the delivery and cost history in <code>outbound_message<\/code>, which you need for reporting. And <code>ON DELETE CASCADE<\/code> runs in the direction you want: message retention drives verification retention, never the reverse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two notes on the sidecar. <code>code_hash<\/code> is a hash, not the code, and not an encrypted code. You never need to read the code back, only to compare a submitted value against it, so a hash is strictly correct and strictly safer. And <code>purpose<\/code> exists because a code generated to authorise a login must not be accepted to authorise a payment. Binding is a property of your application; the API does not do it for you.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"timestamps-four-different-formats-one-column-type\" class=\"wp-block-heading\">Timestamps: Four Different Formats, One Column Type<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The platform returns time in at least four different shapes across endpoints, and every one of them needs to land in a <code>TIMESTAMPTZ<\/code> column in UTC.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Source<\/th><th>Documented format<\/th><th>Example shape<\/th><th>Conversion note<\/th><\/tr><\/thead><tbody><tr><td>OTP generate response<\/td><td>Millisecond epoch as a quoted string<\/td><td><code>\"createTime\":\"1700117933163\"<\/code><\/td><td>Divide by 1000, treat <code>\"0\"<\/code> as null<\/td><\/tr><tr><td>Campaign and schedule read<\/td><td>Millisecond epoch as a quoted string<\/td><td><code>\"timestamp\": \"1562974594713\"<\/code><\/td><td>Same, and <code>lastupdatedTimestamp<\/code> of <code>\"0\"<\/code> means never<\/td><\/tr><tr><td>By-transaction report<\/td><td>Human-readable text<\/td><td><code>\"July 13, 2019 03:44:48\"<\/code><\/td><td>Not ISO, not an epoch. Parse with an explicit format string, and confirm the timezone with the provider rather than assuming<\/td><\/tr><tr><td>Send request <code>scheduleTime<\/code><\/td><td><code>YYYY-MM-DD HH:MM:SS<\/code><\/td><td>Local wall-clock string<\/td><td>Convert from your stored UTC value at request time<\/td><\/tr><tr><td>WhatsApp <code>scheduletime<\/code><\/td><td><code>YYYY-MM-DD HH:MM<\/code><\/td><td>Lowercase <code>t<\/code>, <strong>no seconds<\/strong><\/td><td>A different parameter name casing and a different precision from the SMS equivalent<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That last row deserves emphasis because it is a genuine cross-channel inconsistency: the SMS parameter is <code>scheduleTime<\/code> with a capital T and second precision, and the WhatsApp parameter is <code>scheduletime<\/code> all lowercase with minute precision. A shared scheduling helper that formats one way for both channels produces a silent parameter rejection on one of them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Store one truth and convert at the edges:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- One canonical column. Every read converts INTO it, every write converts OUT of it.\nscheduled_for TIMESTAMPTZ,<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>def fmt_schedule(dt_utc, channel, tz):\n    local = dt_utc.astimezone(tz)\n    if channel == \"whatsapp\":\n        return local.strftime(\"%Y-%m-%d %H:%M\")      # no seconds\n    return local.strftime(\"%Y-%m-%d %H:%M:%S\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Never store a local wall-clock string in the database. The one situation where teams are tempted is a scheduled campaign that must fire at 10:00 local time regardless of a daylight-saving shift. If you have that requirement, store the UTC instant <strong>and<\/strong> the IANA timezone name in a separate column, and recompute. Do not store the string.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-columns-money-depends-on\" class=\"wp-block-heading\">The Columns Money Depends On<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three columns, and each has a common mistake attached.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>cost NUMERIC(12,4)<\/code>, never <code>FLOAT<\/code> or <code>REAL<\/code>.<\/strong> Per-message rates in India are quoted in fractions of a rupee, and a hundred thousand of them summed in floating point will not tie out against an invoice. Four decimal places accommodates sub-paisa rates. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/previewMsg\/\">preview endpoint<\/a> returns an <code>amount<\/code> field as a float and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-sms-length-cost\/\">length and cost endpoint<\/a> returns an integer <code>credit<\/code>; those are two different fields with two different meanings, and conflating them is a reporting bug rather than a rounding bug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>billable_units SMALLINT<\/code>, populated as recipients multiplied by segments.<\/strong> Not as a request count. The single most common cost surprise in messaging is a team that alerts on requests per minute, sees a flat line, and gets a bill that tripled because message bodies crossed a segment boundary after a copy change. The segment arithmetic itself is documented in the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/text-messages-chart\/\">text messages chart<\/a>: 160 GSM-7 characters per segment, 153 when concatenated, 70 for Unicode and 67 when concatenated. Store the computed value on the row at submission time. Do not recompute it later from the body, because the body may be edited, redacted or truncated in your own retention process, and the historical unit count must not move.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>cost<\/code> and <code>billable_units<\/code> are written at submission and are then immutable.<\/strong> This follows directly from the published billing terms, which state that credits are non-refundable once the SMS is successfully submitted to the operator. A message that submits and then fails delivery cost you exactly the same as one that succeeded. If your reporting recomputes cost from delivered rows, you are under-reporting spend by precisely your failure rate, which is the number you least want to be wrong about.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A useful guard is a trigger that refuses to change either column once set. It is a five-line function and it will save an audit at some point.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-columns-observability-depends-on\" class=\"wp-block-heading\">The Columns Observability Depends On<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The four columns you will regret omitting, in the order you will regret them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>submitted_at<\/code> and <code>delivered_at<\/code> as separate columns.<\/strong> The gap between them is submit-to-delivery latency, which is the single most useful health metric a messaging pipeline has, and it cannot be computed if you overwrote one timestamp with the other. A schema with a single <code>completed_at<\/code> column has destroyed this metric permanently and no amount of downstream cleverness recovers it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>message_class<\/code>.<\/strong> This is the dimension every meaningful query groups by. A global delivery rate hides an OTP failure completely, because OTP is usually a single-digit percentage of total volume, so a total collapse of OTP delivery moves the global number by less than normal daily variance. It is invisible in the aggregate by construction, not by accident. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-dlr-summary-report\/\">DLR summary endpoint<\/a> supports a <code>groupby<\/code> of <code>senderid<\/code>, which is the provider-side equivalent of this dimension and a good cross-check against your own numbers, provided you use distinct sender identifiers per class.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>state_reason<\/code> and <code>provider_cause<\/code> as two columns, not one.<\/strong> <code>state_reason<\/code> is your classification, <code>provider_cause<\/code> is their verbatim text. Keep them separate. When the provider adds a new failure cause, your classifier will bucket it as unknown, and the only way to find out what it actually was is to have kept the original string. The vocabulary of causes is explained in the knowledge base entries on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-delivery-reports-dlr\/\">understanding delivery reports<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/dlr-status-meaning\/\">DLR status meanings<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nothing high-cardinality as a metric label.<\/strong> This is a schema-adjacent rule that belongs here because the table is where the temptation originates. Recipient, transaction identifier, message identifier and body text are all legitimate table columns and all catastrophic as monitoring labels. Query the table for those; label the metric with <code>message_class<\/code>, <code>channel<\/code>, <code>state<\/code> and <code>sender_id<\/code> only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two derived queries pay for the whole design. First, the age of the oldest unresolved row, which encodes queue depth, drain rate and stalls in one number:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT COALESCE(\n         EXTRACT(EPOCH FROM (now() - MIN(created_at)))::int,\n         0\n       ) AS oldest_pending_seconds\n  FROM outbound_message\n WHERE state IN ('pending','claimed');<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>COALESCE<\/code> to zero, not to null, is deliberate. A null becomes a gap in the time series, and a gap never triggers an alert, so an empty queue and a dead exporter look identical to your alerting rules. Return a real zero.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, the pending ratio, which is the alarm for a dead delivery-receipt pipeline:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT message_class,\n       count(*) FILTER (WHERE state = 'submitted')::numeric\n         \/ NULLIF(count(*) FILTER (WHERE state IN\n             ('submitted','delivered','failed','expired')), 0) AS still_pending_ratio\n  FROM outbound_message\n WHERE created_at &gt; now() - interval '1 hour'\n GROUP BY message_class;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The denominator is everything that was submitted, not delivered plus failed. If you use delivered plus failed and receipts stop arriving entirely, the ratio freezes at whatever it was and looks perfectly healthy while nothing is being confirmed. The denominator bug is the reason delivery dashboards are so often the last thing to notice an incident.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"indexing-for-the-four-access-patterns\" class=\"wp-block-heading\">Indexing for the Four Access Patterns<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Do not index by intuition. There are exactly four hot query shapes, and each gets one index.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pattern one, claim the next batch of work.<\/strong> High frequency, must not scan.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_outbound_claim\n    ON outbound_message (channel, message_class, created_at)\n    WHERE state = 'pending';<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The partial predicate is what makes this small. A table with a hundred million historical rows has perhaps a few thousand pending at any moment, and the index only contains those.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The claim itself uses <code>FOR UPDATE SKIP LOCKED<\/code>, which is what allows multiple workers to drain the same queue without contending:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>WITH claimed AS (\n    SELECT id FROM outbound_message\n     WHERE state = 'pending'\n       AND channel = 'sms'\n       AND (scheduled_for IS NULL OR scheduled_for &lt;= now())\n     ORDER BY created_at\n     FOR UPDATE SKIP LOCKED\n     LIMIT 500\n)\nUPDATE outbound_message m\n   SET state = 'claimed', claimed_at = now(), attempt_count = attempt_count + 1\n  FROM claimed c\n WHERE m.id = c.id\nRETURNING m.*;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The critical operational rule around this query is not an indexing rule: <strong>the HTTP call to the provider must happen outside the transaction<\/strong>. Holding a database transaction open across a network call to a third party couples your connection pool lifetime to their latency, and one slow upstream then exhausts the pool and takes down everything else sharing the database.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pattern two, find rows stuck in unknown.<\/strong> Runs every minute or two.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_outbound_unresolved\n    ON outbound_message (submitted_at)\n    WHERE state = 'unknown';<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pattern three, oldest item age.<\/strong> Served by the claim index above, since <code>created_at<\/code> is its trailing column and the predicate overlaps. Verify with <code>EXPLAIN<\/code> rather than assuming.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pattern four, correlate by recipient when there is no provider identifier.<\/strong> This is the OTP reconciliation path.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_outbound_correlate\n    ON outbound_message (recipient, sender_id, submitted_at)\n    WHERE provider_txn_id IS NULL AND state IN ('unknown','submitted');<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Plus two lookup indexes that are not hot but are needed for support queries and for reconciliation by identifier:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_outbound_txn ON outbound_message (provider_txn_id)\n    WHERE provider_txn_id IS NOT NULL;\n\nCREATE UNIQUE INDEX uq_outbound_provider_msg ON outbound_message (provider_message_id)\n    WHERE provider_message_id IS NOT NULL;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That last one is the only unique constraint on a provider identifier in the whole design, and it is safe precisely because <code>msgId<\/code> is per-recipient rather than per-request. Being able to enforce it is a good test that you have the three identity levels straight.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Indexes not to create: anything on <code>body<\/code>, anything on <code>state<\/code> alone with no partial predicate, and any index whose leading column is <code>tenant_id<\/code> when you already have the composite unique index. Each unnecessary index is a write cost on the hottest table in the system.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"scheduled-and-campaign-sends-are-different-rows\" class=\"wp-block-heading\">Scheduled and Campaign Sends Are Different Rows<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A scheduled send is not a pending send with a future timestamp bolted on, and the API makes that concrete.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/read-schedule-sms\/\">schedule read endpoint<\/a> at <code>https:\/\/unify.smsgateway.center\/SMSApi\/schedule\/read<\/code> returns each schedule as an object containing <code>uuId<\/code>, <code>status<\/code>, <code>total<\/code>, <code>timestamp<\/code>, <code>scheduledTimestamp<\/code> and <code>lastupdatedTimestamp<\/code>. Two operational facts follow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The schedule lives on the provider side, keyed by <code>uuId<\/code>.<\/strong> Once you submit with a <code>scheduleTime<\/code>, the provider holds it. Cancelling or amending it later requires that <code>uuId<\/code>, so if you did not persist the transaction identifier at submission you cannot cancel your own scheduled campaign. Persist it before you need it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The schedule read endpoint takes no filter parameters.<\/strong> Its documented parameter list is authentication and <code>output<\/code> only. There is no date range, no pagination and no status filter, so it returns whatever it returns and you cannot page it. Treat it as a reconciliation cross-check, not as a source of truth. Your table is the source of truth for what you intended to schedule; theirs is the record of what they are holding.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Model the state accordingly: a scheduled row sits in <code>pending<\/code> with <code>scheduled_for<\/code> set until you submit it, then moves to <code>submitted<\/code> with <code>provider_txn_id<\/code> populated and <code>scheduled_for<\/code> retained for reference. The <code>expired<\/code> state exists for the case where the scheduled window passed while the row was still <code>pending<\/code>, which happens when a worker was down over the window, and which you want to be able to count rather than to silently send late.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Campaigns add the third identity level. Because the campaign read endpoint describes fetching details of a <em>split<\/em> campaign, a campaign identifier can correspond to more than one <code>uuId<\/code>. Store <code>provider_campaign_id<\/code> on every row of the campaign, and never assume it maps one-to-one to a transaction. If you need per-campaign aggregation, group by <code>provider_campaign_id<\/code> in your own table rather than trusting that a single transaction lookup covers the campaign.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"partitioning-retention-and-the-report-window\" class=\"wp-block-heading\">Partitioning, Retention and the Report Window<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This table grows without bound and it is the hottest table in the system, so partitioning is not premature optimisation, it is the default.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE outbound_message (\n    -- ... columns as above ...\n) PARTITION BY RANGE (created_at);\n\nCREATE TABLE outbound_message_2026_08 PARTITION OF outbound_message\n    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Monthly range partitioning on <code>created_at<\/code> is the right default for three reasons. Detaching an old partition is a metadata operation rather than a mass delete, so retention becomes instant rather than an hours-long vacuum problem. The hot partition stays small enough that the claim index fits comfortably in cache. And your reporting queries almost always carry a date predicate, so partition pruning does real work.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note that partitioning on <code>created_at<\/code> means the primary key and every unique index must include <code>created_at<\/code>. Plan for that at creation time, because retrofitting it is a full table rewrite.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On retention, one honest constraint. <strong>The provider-side delivery report retention window is not documented publicly.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-report\/\">zipped delivery report endpoint<\/a> at <code>https:\/\/unify.smsgateway.center\/SMSApi\/report\/day<\/code> retrieves a report for a single <code>date<\/code> in <code>YYYY-MM-DD<\/code> format, one day per call, and the documentation describes it as covering past campaigns without stating how far back that goes. Because you cannot rely on an undocumented window, the correct posture is that <strong>your table is the archive<\/strong>. Keep your own rows for as long as your finance and compliance requirements demand, and treat the provider&#8217;s reports as a reconciliation source with an unknown horizon rather than as your history of record.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The day-granular shape of that endpoint has a practical consequence worth building for: a backfill after an outage is a loop over dates, one HTTP call per day, not a single ranged query. Write the backfill job that way from the start and give it a checkpoint so it resumes rather than restarting.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"migrating-an-existing-naive-sends-table\" class=\"wp-block-heading\">Migrating an Existing Naive Sends Table<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most readers already have a table. It usually looks something like <code>sends(id, phone, message, sent boolean, response text, created_at)<\/code>. Here is the expand-and-contract path that gets you from there to the design above without a maintenance window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step one, add the new columns as nullable.<\/strong> <code>business_key<\/code>, <code>attempt_key<\/code>, <code>state<\/code>, <code>provider_txn_id<\/code>, <code>provider_message_id<\/code>, <code>submitted_at<\/code>, <code>delivered_at<\/code>, <code>billable_units<\/code>, <code>cost<\/code>. All nullable, no constraints yet. This is a metadata-only change on modern Postgres and is safe on a live table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step two, dual-write.<\/strong> Change the application to populate both the old and the new columns. Read still comes from the old ones. Run this for at least one full business cycle, which usually means a week, so that weekly batch jobs exercise the new path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step three, backfill in batches.<\/strong> Derive <code>state<\/code> from the old boolean plus the stored response text. Be conservative: any row where the old <code>sent<\/code> flag is true but no provider identifier can be extracted from the stored response becomes <code>unknown<\/code>, not <code>submitted<\/code>. Backfilling optimistically is the same mistake as retrying optimistically, one migration removed. Batch it by primary key range with a sleep between batches so you do not saturate replication.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>UPDATE outbound_message\n   SET state = CASE\n        WHEN provider_message_id IS NOT NULL THEN 'delivered'\n        WHEN provider_txn_id     IS NOT NULL THEN 'submitted'\n        WHEN sent IS TRUE                    THEN 'unknown'\n        ELSE 'pending'\n       END\n WHERE state IS NULL\n   AND id BETWEEN :lo AND :hi;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step four, add the constraints.<\/strong> Unique index on <code>(tenant_id, business_key)<\/code>, the state transition trigger, the sentinel check constraint. Build indexes with <code>CREATE INDEX CONCURRENTLY<\/code>. Expect the unique index build to fail the first time and reveal genuine historical duplicates. That is the migration doing its job. Resolve them by marking the later rows superseded rather than deleting them, because they represent messages that really were sent and really were billed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step five, cut reads over, then drop the old columns.<\/strong> Leave a full release between the read cutover and the drop, so a rollback does not require a restore.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The one thing not to do is a big-bang table swap. The outbound message table is written to by every send path in the system, and the paths you forget about are always the ones that matter, typically an old cron job and something a colleague wrote in a different language.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"building-the-table-in-ten-steps\" class=\"wp-block-heading\">Building the Table in Ten Steps<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Enumerate your message classes<\/strong> before writing any DDL. OTP, transactional and promotional at minimum. This drives <code>message_class<\/code>, and it drives every alert threshold you will set later.<\/li>\n\n\n\n<li><strong>Define the business key composition<\/strong> and write down which fields it includes. Confirm it excludes channel, then confirm again, because the fallback chain is where that decision is tested.<\/li>\n\n\n\n<li><strong>Create the enums first<\/strong>, <code>message_state<\/code> and <code>message_channel<\/code>, so the table definition cannot invent ad hoc string states.<\/li>\n\n\n\n<li><strong>Create the table partitioned by <code>created_at<\/code><\/strong>, with all provider identifier columns typed <code>TEXT<\/code> and nullable, and with <code>cost<\/code> as <code>NUMERIC<\/code>.<\/li>\n\n\n\n<li><strong>Add the unique index on <code>(tenant_id, business_key)<\/code><\/strong> and prove idempotency with a concurrency test: two workers inserting the same business key simultaneously against a real database, asserting exactly one row.<\/li>\n\n\n\n<li><strong>Add the state transition trigger<\/strong> and write a test that asserts the <code>unknown<\/code> to <code>pending<\/code> transition raises. That test is the most valuable one in the suite.<\/li>\n\n\n\n<li><strong>Add the four access-pattern indexes<\/strong> and run <code>EXPLAIN (ANALYZE, BUFFERS)<\/code> on each of the four queries against a table seeded with realistic volume. An index that is not used is a write tax.<\/li>\n\n\n\n<li><strong>Write the response normalisation layer<\/strong>, including <code>clean_id<\/code> and <code>clean_epoch_ms<\/code>, and route every provider response through it. Add a test asserting that a <code>transactionId<\/code> of <code>\"0\"<\/code> produces <code>NULL<\/code>.<\/li>\n\n\n\n<li><strong>Write the reconciliation sweep<\/strong> with the three-valued outcome, and assert in a test that two candidate delivery records inside the window yield <code>ambiguous<\/code> rather than a pick.<\/li>\n\n\n\n<li><strong>Wire the two derived queries<\/strong>, oldest item age and pending ratio, into your monitoring before you send a single production message. If you add them after the first incident, the first incident is the one you will not have data for.<\/li>\n<\/ol>\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>Design question<\/th><th>Choose this<\/th><th>When the alternative is right<\/th><\/tr><\/thead><tbody><tr><td>Row granularity<\/td><td>One row per recipient per attempt<\/td><td>Never. One row per request loses per-recipient outcomes permanently<\/td><\/tr><tr><td>Provider identifier column type<\/td><td><code>TEXT<\/code><\/td><td>Never anything numeric. Nineteen-digit values break JavaScript numeric parsing<\/td><\/tr><tr><td>State representation<\/td><td>Single enum column<\/td><td>Never booleans. Booleans permit contradictory states<\/td><\/tr><tr><td>Business key includes channel<\/td><td>No<\/td><td>Only if two channels genuinely represent two separate business notifications<\/td><\/tr><tr><td>OTP storage<\/td><td>Shared table plus narrow sidecar<\/td><td>A fully separate table if regulatory isolation of authentication data is mandated<\/td><\/tr><tr><td><code>unknown<\/code> resolution<\/td><td>Reconcile by reading, or leave unresolved<\/td><td>Never by requeueing. Requeueing bills twice<\/td><\/tr><tr><td>Cost column type<\/td><td><code>NUMERIC(12,4)<\/code><\/td><td>Never float. Sums must tie to an invoice<\/td><\/tr><tr><td>Cost written at<\/td><td>Submission<\/td><td>Never at delivery. Billing fires at submission<\/td><\/tr><tr><td>Partitioning<\/td><td>Monthly range on <code>created_at<\/code><\/td><td>Weekly if volume exceeds roughly fifty million rows per month<\/td><\/tr><tr><td>Retention horizon<\/td><td>Driven by your own finance and compliance needs<\/td><td>Not by the provider report window, which is undocumented<\/td><\/tr><tr><td>Batch OTP sends<\/td><td>Never<\/td><td>No exception. There is no transaction identifier to share<\/td><\/tr><tr><td>Correlation when no identifier exists<\/td><td>Three-valued matched, not found, ambiguous<\/td><td>Never two-valued. Two-valued forces a guess on resends<\/td><\/tr><tr><td>Scheduled sends<\/td><td>Same table, <code>scheduled_for<\/code> set, provider <code>uuId<\/code> persisted<\/td><td>A separate table only if scheduling logic is genuinely independent of sending<\/td><\/tr><tr><td>Provider <code>msgId<\/code> uniqueness<\/td><td>Unique partial index<\/td><td>Safe because it is per-recipient, unlike <code>transactionId<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"pre-production-checklist\" class=\"wp-block-heading\">Pre-Production Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Identity and keys<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>business_key<\/code> is deterministic and computed before any network call<\/li>\n\n\n\n<li><code>business_key<\/code> excludes the channel<\/li>\n\n\n\n<li>Unique index exists on <code>(tenant_id, business_key)<\/code> and is enforced, not advisory<\/li>\n\n\n\n<li><code>attempt_key<\/code> is a fresh UUID per row and appears in every log line<\/li>\n\n\n\n<li>Three separate columns exist for campaign, transaction and per-recipient identifiers<\/li>\n\n\n\n<li>Only <code>provider_message_id<\/code> carries a unique constraint<\/li>\n\n\n\n<li>All provider identifier columns are <code>TEXT<\/code><\/li>\n\n\n\n<li>A test asserts that a nineteen-digit identifier survives a full write and read round trip unchanged<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Sentinels and parsing<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>clean_id<\/code> maps <code>\"0\"<\/code> and empty string to <code>NULL<\/code><\/li>\n\n\n\n<li><code>clean_epoch_ms<\/code> maps <code>\"0\"<\/code> to <code>NULL<\/code>, not to 1970<\/li>\n\n\n\n<li>A check constraint rejects sentinel values at the database level<\/li>\n\n\n\n<li>Every provider response passes through exactly one normalisation function<\/li>\n\n\n\n<li><code>statusCode<\/code> is parsed defensively; it is quoted on some endpoints and unquoted on others<\/li>\n\n\n\n<li>Report field names containing spaces are mapped explicitly, not by automatic binding<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>State machine<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>state<\/code> is an enum, not a set of booleans<\/li>\n\n\n\n<li>A database trigger blocks <code>unknown<\/code> to <code>pending<\/code><\/li>\n\n\n\n<li>A database trigger blocks all transitions out of terminal states<\/li>\n\n\n\n<li><code>rejected<\/code> is distinguished from <code>failed<\/code>, and is not billable<\/li>\n\n\n\n<li><code>expired<\/code> exists for scheduled rows whose window passed unclaimed<\/li>\n\n\n\n<li>Recipients listed in <code>invalidMobile<\/code> are written as <code>rejected<\/code>, not <code>submitted<\/code><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two clocks<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>submitted_at<\/code> and <code>delivered_at<\/code> are separate columns<\/li>\n\n\n\n<li>Submit-to-delivery latency is computable from stored data alone<\/li>\n\n\n\n<li>All timestamps are <code>TIMESTAMPTZ<\/code> in UTC<\/li>\n\n\n\n<li>Wall-clock strings are formatted at request time, never stored<\/li>\n\n\n\n<li>The WhatsApp minute-precision scheduling format is handled separately from the SMS second-precision format<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Money<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>cost<\/code> is <code>NUMERIC<\/code>, not float<\/li>\n\n\n\n<li><code>billable_units<\/code> is recipients multiplied by segments, not a request count<\/li>\n\n\n\n<li>Cost is written at submission and is immutable thereafter<\/li>\n\n\n\n<li>Reporting sums cost over submitted rows, not over delivered rows<\/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>The sweep is a read-only operation against the report endpoints<\/li>\n\n\n\n<li>The correlation outcome is three-valued<\/li>\n\n\n\n<li>An <code>ambiguous<\/code> result never resolves to a guess<\/li>\n\n\n\n<li><code>correlation_window_ms<\/code> is stored on the row<\/li>\n\n\n\n<li>Backfill after an outage loops one day at a time against the day-granular report endpoint, with a resumable checkpoint<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Indexes and operations<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Four partial indexes exist, matching the four access patterns<\/li>\n\n\n\n<li><code>EXPLAIN<\/code> confirms each is used<\/li>\n\n\n\n<li>Claiming uses <code>FOR UPDATE SKIP LOCKED<\/code><\/li>\n\n\n\n<li>The provider HTTP call happens outside the claiming transaction<\/li>\n\n\n\n<li>The table is partitioned by <code>created_at<\/code><\/li>\n\n\n\n<li>Oldest-item-age returns zero, not null, on an empty queue<\/li>\n\n\n\n<li>The pending ratio uses submitted as its denominator<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes-that-show-up-later\" class=\"wp-block-heading\">Ten Mistakes That Show Up Later<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>One row per API request.<\/strong> Discovered the first time a customer asks why one person in a ten thousand recipient batch did not get the message, and there is no row to point at.<\/li>\n\n\n\n<li><strong>Storing the transaction identifier in a uniquely constrained <code>message_id<\/code> column.<\/strong> The constraint breaks on the first real batch, gets dropped under pressure, and never comes back.<\/li>\n\n\n\n<li><strong>Parsing provider identifiers as numbers.<\/strong> Works in every test with short synthetic values, corrupts silently in production where they are nineteen digits.<\/li>\n\n\n\n<li><strong>Persisting the string <code>\"0\"<\/code>.<\/strong> Produces reconciliation queries that match everything and find nothing, plus dashboards full of 1970.<\/li>\n\n\n\n<li><strong>Requeueing rows from <code>unknown<\/code>.<\/strong> The single most expensive bug in this domain, because every occurrence is a duplicate billed message and, on the OTP path, a duplicate code that confuses the user about which one is valid.<\/li>\n\n\n\n<li><strong>A single <code>completed_at<\/code> column.<\/strong> Destroys submit-to-delivery latency permanently. No downstream fix exists.<\/li>\n\n\n\n<li><strong>Computing cost from delivered rows.<\/strong> Under-reports spend by exactly the failure rate, which is the number you least want wrong.<\/li>\n\n\n\n<li><strong>Including the channel in the business key.<\/strong> The fallback chain then delivers the same notification twice over two channels and both look correct in the data.<\/li>\n\n\n\n<li><strong>Trusting the top-level <code>status<\/code> field alone.<\/strong> Recipients in <code>invalidMobile<\/code> get marked submitted, never receive receipts, and permanently inflate the pending-message alert until somebody mutes it.<\/li>\n\n\n\n<li><strong>No partitioning.<\/strong> Fine for a year, then the retention delete takes six hours, holds locks, and turns a routine cleanup into an incident.<\/li>\n<\/ol>\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\">This section exists because the honest boundary of what is documented is more useful than a confident guess.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>It does not state the provider-side delivery report retention window.<\/strong> The day-granular report endpoint is documented as covering past campaigns; how far back is not published. The design treats your own table as the archive precisely because of this.<\/li>\n\n\n\n<li><strong>It does not state any throughput or transactions-per-second ceiling.<\/strong> No numeric TPS figure appears in the developer documentation, so none appears here.<\/li>\n\n\n\n<li><strong>It does not claim a maximum batch size.<\/strong> The batch endpoint accepts a comma-separated recipient list; the practical ceiling depends on account configuration and is not published as a single number.<\/li>\n\n\n\n<li><strong>It does not claim the outbound webhook push payload field names.<\/strong> The webhook registration endpoint is documented, but the schema of what is pushed to your receiver is not, so the reconciliation design in this article is built on the pollable report endpoints, whose field names are documented.<\/li>\n\n\n\n<li><strong>It does not claim a timezone for the human-readable timestamps<\/strong> in the by-transaction report. The format is documented; the zone is not stated, and assuming it is a bug waiting to happen at the daylight-saving boundary.<\/li>\n\n\n\n<li><strong>It does not claim what a campaign split is triggered by.<\/strong> The documentation describes fetching details of a split campaign, which establishes that splits exist. It does not say what causes one, so the design simply tolerates the many-to-one relationship rather than predicting it.<\/li>\n\n\n\n<li><strong>It does not claim whether an OTP resend inside the validity window returns the same code or a new one.<\/strong> The generate response includes a <code>type<\/code> field documented only with the value <code>new<\/code>; other values are not enumerated.<\/li>\n\n\n\n<li><strong>It does not state rate limits on the reporting, summary or dashboard endpoints.<\/strong> None are published, so the reconciliation sweep in this article is designed to be paced conservatively rather than to a documented budget.<\/li>\n\n\n\n<li><strong>It does not recommend a specific retention period in days.<\/strong> That is a finance and compliance decision specific to your jurisdiction and contracts, not a technical one.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"frequently-asked-questions\" class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is this just the transactional outbox pattern with more columns?<\/strong><br>No. The outbox pattern solves writing your business change and your send intent atomically, and you should still use it for that. This table solves what happens after: a second asynchronous completion on a different clock, billing that fires at the earlier completion, one identifier covering many recipients, and at least one path with no identifier at all. The outbox pattern has nothing to say about any of those because a broker acknowledgement resolves the whole story in one step.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should the outbox table and the outbound message table be the same table?<\/strong><br>They can be, and for most teams they should be. Keep one table, let <code>pending<\/code> be the outbox state, and let the relay be the claim query. Two tables means a hand-off between them, and hand-offs are where messages get lost or duplicated. If you already have a separate outbox for other event types, keep messages in this table and let the outbox emit an intent that inserts here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why must <code>unknown<\/code> never go back to <code>pending<\/code>?<\/strong><br>Because an ambiguous outcome means the message may already have been submitted, and submission is the billing event. Returning it to the queue guarantees a second submission if the first one landed. The only safe resolutions are evidence from a read of the delivery reports, or leaving it unresolved for a human. An unresolved row costs nothing; a duplicate costs money and, for an OTP, costs user trust.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I reconcile a message that has no provider identifier?<\/strong><br>Reconstruct from recipient, sender identifier and a tight window around your own submission timestamp, matched against the delivery report list. Accept three outcomes: exactly one candidate is a match, zero candidates is not found, and two or more is ambiguous. Never pick between candidates. Two codes to one number ninety seconds apart is the resend case and it is common.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What window should I use for that reconstruction?<\/strong><br>There is no documented figure to anchor on, so treat it as a tunable and store the value you used on each row. Start narrow. A window wide enough to never miss is also wide enough to be ambiguous constantly, which is worse, because a not-found result tells you to investigate while an ambiguous result tells you nothing at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use the <code>duplicatecheck<\/code> parameter instead of a business key?<\/strong><br>No. It is documented as removing duplicate mobile numbers, which is deduplication of recipients within a single request. It has no cross-request memory and no time window, so it cannot prevent the case that actually hurts you, which is the same request arriving twice after a timeout and a retry.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I store the message body?<\/strong><br>Yes, at least for a retention period that covers your dispute and compliance needs, because reconstructing what was sent from a template plus variables is unreliable once the template has been edited. Do budget for it: bodies dominate the storage of this table, and they are the first thing to redact or truncate in an older partition if space becomes a problem. Never store them in a monitoring label.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle a message that spans multiple segments?<\/strong><br>Store <code>segments<\/code> and <code>encoding<\/code> on the row, computed at write time with a counter that is correct at code point level rather than at UTF-16 code unit level. Set <code>billable_units<\/code> from it. Do not recompute segments later from the stored body, because a redaction or truncation in your own retention process would silently change a historical billing figure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need a separate table per channel?<\/strong><br>No, and it will hurt you. Every reliability behaviour is channel-independent, so per-channel tables mean writing the state machine, the reconciler and the alerting several times, and the copies will diverge. Use one table with a <code>channel<\/code> column and a small per-channel adapter layer at the API boundary, where the actual differences live: different parameter names, different scheduling precision, different response field names.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the right primary key?<\/strong><br>A generated <code>BIGINT<\/code> identity column, with the business key enforced by a separate unique index. Do not make the business key the primary key; it is wide, it is a hash, and every foreign key and every index leaf then carries the whole thing. If you partition by <code>created_at<\/code>, remember that the primary key must include the partition column.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I detect that delivery receipts have stopped arriving?<\/strong><br>Alert on the ratio of rows still in <code>submitted<\/code> to all rows that were ever submitted in the window, grouped by message class, and separately alert on seconds since the last receipt of any kind. The second one is three lines of SQL and is the highest-value alert in the entire system, because a dead receipt pipeline looks perfectly healthy on any dashboard built from delivered over delivered plus failed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I store one row per retry attempt or update the existing row?<\/strong><br>Update the row and increment <code>attempt_count<\/code> for transport-level retries that never resulted in a submission. Create a new row only when a genuinely new physical message is sent, which for a resent OTP is the correct model, since each code is a separate message with its own delivery outcome and its own cost. The distinction is whether the provider was billed. Billed means a new row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How large does this table get?<\/strong><br>Volume multiplied by retention, dominated by the body column. At a hundred thousand messages a day with twelve months of retention you are in the tens of millions of rows, which Postgres handles comfortably when partitioned monthly and indexed with partial predicates as described above. The failure mode is not row count, it is unpartitioned retention deletes and indexes without partial predicates.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is any of this specific to India?<\/strong><br>The DLT columns are, <code>dlt_entity_id<\/code> and <code>dlt_template_id<\/code>, along with the requirement that the body match an approved template exactly. Everything else is general. If you operate only outside India, keep those columns nullable rather than dropping them, because template-bound messaging is spreading to other regulatory regimes and a nullable column is far cheaper than a later migration. Template mismatch handling is covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT template rejected: reasons and fixes<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\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>Build it against a real API, not a mock.<\/strong> Every field name, response shape and identifier format in this article comes from the live <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">SMSGatewayCenter developer documentation<\/a>. Create an account, activate the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">sandbox<\/a>, and run your schema against real responses before you write the second half of your pipeline. If you are migrating an existing messaging system and want the identifier and reconciliation semantics confirmed for your specific account configuration, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">get in touch with the team<\/a>.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>Retries, idempotency, delivery reconciliation, cost attribution and every alert you will ever build all read from one table. Here is how to design it correctly the first time, using the real identifier, timestamp and status shapes the SMSGatewayCenter API actually returns.<\/p>\n","protected":false},"author":118,"featured_media":2825,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2179,2177,2016,1994,404,2181,2018,2176,2031,2030,2180,2178,481,13,632],"class_list":["post-2824","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-data-modelling","tag-database-schema","tag-delivery-reports","tag-dlr","tag-dlt","tag-engineering","tag-idempotency","tag-message-queue","tag-messaging-architecture","tag-otp","tag-outbox-pattern","tag-postgresql","tag-sms-api","tag-sms-gateway","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2824","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=2824"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2824\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2825"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2824"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2824"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2824"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}