{"id":2638,"date":"2026-08-14T14:30:12","date_gmt":"2026-08-14T09:00:12","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2638"},"modified":"2026-08-14T14:30:14","modified_gmt":"2026-08-14T09:00:14","slug":"message-idempotency-preventing-duplicate-sends","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/","title":{"rendered":"Message Idempotency at Scale: Preventing Duplicate SMS and WhatsApp Sends"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A client-side timeout does not mean your message was not sent. Because billing fires at submission, a blind retry bills you twice and delivers twice. This is the full engineering guide to two-level idempotency keys, claim-before-send state machines, Celery and BullMQ redelivery semantics, cross-channel deduplication across the SMS and WhatsApp APIs, and why the duplicatecheck parameter does not do what most teams assume it does.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/message-idempotency-preventing-duplicate-sends.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/message-idempotency-preventing-duplicate-sends-1024x584.webp\" alt=\"Diagram showing two duplicate message paths converging into a deduplication gate that emits a single delivered message\" class=\"wp-image-2639\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/message-idempotency-preventing-duplicate-sends-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/message-idempotency-preventing-duplicate-sends-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/message-idempotency-preventing-duplicate-sends-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/message-idempotency-preventing-duplicate-sends.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/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=\"https:\/\/claude.ai\/cowork\/local_d8e3bb45-66fe-4230-a3c2-f11a99a4ffef#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#executive-summary\">Executive Summary<\/a><\/li>\n\n\n\n<li><a href=\"#the-ambiguous-timeout-is-the-whole-problem\">The Ambiguous Timeout Is the Whole Problem<\/a><\/li>\n\n\n\n<li><a href=\"#what-duplicatecheck-actually-does\">What duplicatecheck Actually Does, and What It Does Not Do<\/a><\/li>\n\n\n\n<li><a href=\"#the-seven-layers\">The Seven Layers That Generate Duplicates<\/a><\/li>\n\n\n\n<li><a href=\"#two-level-idempotency-keys\" data-type=\"internal\" data-id=\"#two-level-idempotency-keys\">Two-Level Idempotency Keys<\/a><\/li>\n\n\n\n<li><a href=\"#the-claim\">The Claim-Before-Send State Machine<\/a><\/li>\n\n\n\n<li><a href=\"#python-celery\">Python: Celery, acks_late and the Duplicate Generator<\/a><\/li>\n\n\n\n<li><a href=\"#nodejs-bullmq\">Node.js: BullMQ, Stalled Jobs and Redis Claims<\/a><\/li>\n\n\n\n<li><a href=\"#php-laravel\">PHP and Laravel: ShouldBeUnique Is Not Idempotency<\/a><\/li>\n\n\n\n<li><a href=\"#curl-raw-http-contract\" data-type=\"internal\" data-id=\"#curl-raw-http-contract\">cURL and the Raw HTTP Contract<\/a><\/li>\n\n\n\n<li><a href=\"#batch-sends\" data-type=\"internal\" data-id=\"#batch-sends\">Batch Sends: One transactionId, Many Recipients<\/a><\/li>\n\n\n\n<li><a href=\"#cross-channel\" data-type=\"internal\" data-id=\"#cross-channel\">Cross-Channel Deduplication Across SMS and WhatsApp<\/a><\/li>\n\n\n\n<li><a href=\"#otp-is-a-special-case\">OTP Is a Special Case<\/a><\/li>\n\n\n\n<li><a href=\"#reconciling-with-delivery-reports\" data-type=\"internal\" data-id=\"#reconciling-with-delivery-reports\">Reconciling With Delivery Reports<\/a><\/li>\n\n\n\n<li><a href=\"#duplicates-you-cannot-prevent\">Duplicates You Cannot Prevent<\/a><\/li>\n\n\n\n<li><a href=\"#exactly-once-is-a-myth\" data-type=\"internal\" data-id=\"#exactly-once-is-a-myth\">Exactly-Once Is a Myth, Effectively-Once Is Achievable<\/a><\/li>\n\n\n\n<li><a href=\"#ten-steps-to-an-idempotent-send-path\" data-type=\"internal\" data-id=\"#ten-steps-to-an-idempotent-send-path\">Ten Steps to an Idempotent Send Path<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\" data-type=\"internal\" data-id=\"#decision-matrix\">Decision Matrix: Which Guard Belongs Where<\/a><\/li>\n\n\n\n<li><a href=\"#pre-production-checklist\" data-type=\"internal\" data-id=\"#pre-production-checklist\">Pre-Production Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes-that-cause-duplicate-sends\" data-type=\"internal\" data-id=\"#ten-mistakes-that-cause-duplicate-sends\">Ten Mistakes That Cause Duplicate Sends<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\" data-type=\"internal\" data-id=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A duplicate SMS is almost never caused by the gateway sending twice. It is caused by your application submitting twice, because a network timeout told you nothing about whether the first submission was accepted. The fix is to make the send path idempotent at the application layer: derive a deterministic business key from the thing that caused the message to exist, claim that key in a durable store with a uniqueness constraint before you open the HTTP connection, and treat the claim, not the HTTP response, as the record of truth. The gateway&#8217;s <code>duplicatecheck<\/code> parameter is not a substitute for this, because it de-duplicates the recipient list inside a single request rather than de-duplicating repeated requests over time.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A client-side timeout on <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> is <strong>ambiguous<\/strong>. The message may have been accepted, billed and delivered. Retrying without a guard bills you twice and delivers twice.<\/li>\n\n\n\n<li>Billing fires at <strong>submission<\/strong>, not delivery. SMSGatewayCenter states plainly that &#8220;Credits are non-refundable once SMS is successfully submitted to the operator.&#8221; A duplicate is not just a customer experience problem, it is a line item.<\/li>\n\n\n\n<li><code>duplicatecheck<\/code> is documented as &#8220;Enable to remove duplicate mobile numbers. Default is true.&#8221; That is <strong>within-request recipient deduplication<\/strong>. Do not model it as a cross-request retry guard.<\/li>\n\n\n\n<li>Use a <strong>two-level key<\/strong>: a stable business key that identifies the logical message, and a per-attempt key that identifies the HTTP attempt. The business key is what you enforce uniqueness on.<\/li>\n\n\n\n<li><strong>Claim before send.<\/strong> Insert the business key into a table with a unique index, or <code>SET NX<\/code> it in Redis, and only then make the API call. Never make the call and hope to write state afterwards.<\/li>\n\n\n\n<li>Celery <code>acks_late<\/code> and BullMQ stalled-job recovery are <strong>at-least-once by design<\/strong>. Enabling them without an idempotency guard converts a worker crash into a guaranteed duplicate message.<\/li>\n\n\n\n<li>The Send SMS response carries a <strong>single top-level <code>transactionId<\/code><\/strong> and an <code>invalidMobile<\/code> field. Per-recipient correlation comes from the delivery report API, not from the send response.<\/li>\n\n\n\n<li>Cross-channel fallback between <code>SMSApi\/send<\/code> and <code>WAApi\/send<\/code> needs a <strong>channel-agnostic<\/strong> business key, otherwise a fallback becomes a double-notify.<\/li>\n\n\n\n<li>Exactly-once delivery does not exist. <strong>At-least-once transport plus an idempotent receiver<\/strong> is what production systems actually build.<\/li>\n<\/ul>\n\n\n\n<h2 id=\"executive-summary\" class=\"wp-block-heading\">Executive Summary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every messaging pipeline that grows past a few thousand sends a day eventually ships a duplicate. It usually happens on the worst possible day: a partial network incident, a slow upstream, a queue that redelivers a batch, and suddenly a cohort of customers gets the same OTP twice, or the same payment reminder three times. The support tickets are annoying. The regulatory exposure in India, where every commercial message is bound to a registered template and a consent record, is worse. The bill is worse still, because the platform charges on submission.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reflex fix is to add a retry cap or a sleep. Both are wrong, because they attack frequency rather than correctness. A pipeline that sends a duplicate 1 percent of the time is not fixed by making it 0.1 percent. It is fixed by making duplicate submission structurally impossible for a given logical message, which means the send path needs an identity, and that identity needs to be enforced by something with a uniqueness guarantee.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This article builds that. It starts from the ambiguous timeout, which is the root cause almost every team misdiagnoses. It corrects a widespread misreading of the <code>duplicatecheck<\/code> parameter. It then walks the seven distinct layers of a real messaging stack that can each independently emit a duplicate, and gives a concrete guard for each. There is runnable code for Python with Celery, Node.js with BullMQ, and PHP with Laravel, plus the raw cURL contract. It covers batch semantics, cross-channel deduplication when you fall back from SMS to WhatsApp, the special constraints of OTP, reconciliation against delivery reports, and the small class of duplicates that originate below your application and cannot be prevented at all, only detected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a companion to the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API retry strategy guide<\/a>, which covers <em>when<\/em> to retry. This article covers <em>how to retry without duplicating<\/em>. Read both. A retry policy without an idempotency guard is a duplicate generator with a schedule.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-ambiguous-timeout-is-the-whole-problem\" class=\"wp-block-heading\">The Ambiguous Timeout Is the Whole Problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Consider the simplest possible send. Your application posts to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> with a 10 second timeout. At 10.0 seconds the client raises a timeout and your code catches it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What do you actually know at that moment?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You know your process stopped waiting. That is all. You do not know whether the TCP connection was established, whether the request body was fully written, whether the platform parsed it, whether it debited your wallet, whether it handed the message to the operator, or whether the operator has already delivered it to the handset. The response that would have told you all of that is either still in flight, or was written to a socket you have already closed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the two generals problem in its most expensive form. There is a taxonomy that matters here, and most HTTP clients obscure it:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Failure<\/th><th>What it means<\/th><th>Safe to retry blind?<\/th><\/tr><\/thead><tbody><tr><td>DNS resolution failure<\/td><td>Nothing left your process<\/td><td>Yes<\/td><\/tr><tr><td>TCP connect timeout<\/td><td>No connection established, no bytes sent<\/td><td>Yes<\/td><\/tr><tr><td>TLS handshake failure<\/td><td>Connection established, no application data sent<\/td><td>Yes<\/td><\/tr><tr><td>Request write error before body flush<\/td><td>Partial or no request received<\/td><td>Almost always, but treat as ambiguous<\/td><\/tr><tr><td><strong>Read timeout after full request sent<\/strong><\/td><td><strong>Unknown. Request may be fully processed<\/strong><\/td><td><strong>No<\/strong><\/td><\/tr><tr><td>Connection reset after request sent<\/td><td>Unknown<\/td><td>No<\/td><\/tr><tr><td>HTTP 5xx from the platform<\/td><td>Platform received it and errored. May or may not have debited<\/td><td>No<\/td><\/tr><tr><td>HTTP 4xx with a parseable error body<\/td><td>Definitively rejected, nothing was sent<\/td><td>Retry only after fixing the request<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The critical row is the read timeout. Python&#8217;s <code>requests<\/code> collapses connect and read timeouts under a single <code>Timeout<\/code> exception unless you inspect the subclass, which is precisely why so many Python pipelines double-send. <code>httpx<\/code> separates them into <code>ConnectTimeout<\/code> and <code>ReadTimeout<\/code>, and that distinction is worth switching clients for. In Node, <code>fetch<\/code> with <code>AbortSignal.timeout<\/code> gives you a <code>TimeoutError<\/code> that is inherently ambiguous, because the abort fires the same way whether the request was written or not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The economics make this asymmetric. Consider a transactional send at the published 100,000 unit slab rate of INR 0.14 per SMS documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/transactional-sms-pricing\/\">transactional SMS pricing page<\/a>. One duplicate costs 0.14 rupees, which is nothing. One million sends with a 0.5 percent ambiguous-timeout rate and a blind retry costs 5,000 duplicate submissions, or 700 rupees, which is still not much. The cost is not the money. The cost is that 5,000 customers received a duplicate message, and if those were OTPs, an unknown fraction of them entered the wrong code, failed authentication, and either churned or called support. If they were promotional, you have 5,000 people with a concrete reason to reply STOP. The cost mechanics of over-sending are covered in more depth in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-india-what-drives-cost\/\">bulk SMS pricing breakdown<\/a>, but the reputational cost dominates the credit cost by orders of magnitude.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the rule is simple and absolute: <strong>an ambiguous outcome must never be resolved by re-submitting an unguarded request.<\/strong> It is resolved by re-submitting a request that carries an identity the system has already claimed, or by not re-submitting at all and reconciling later.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/ambiguous-timeout.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/ambiguous-timeout-1024x584.webp\" alt=\"Diagram about The Ambiguous Timeout Is the Whole Problem\" class=\"wp-image-2640\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/ambiguous-timeout-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/ambiguous-timeout-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/ambiguous-timeout-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/ambiguous-timeout.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-duplicatecheck-actually-does\" class=\"wp-block-heading\">What duplicatecheck Actually Does, and What It Does Not Do<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This section corrects a misconception that is common enough to be worth stating bluntly, and it is the single most useful paragraph in this article.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Send SMS API accepts an optional parameter named <code>duplicatecheck<\/code>. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS API documentation<\/a> describes it in the optional parameters table as:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><code>duplicatecheck<\/code> | <code>true|false<\/code> | Enable to remove duplicate mobile numbers. Default is true.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Read that carefully. It removes duplicate <strong>mobile numbers<\/strong>. The unit of deduplication is a recipient inside a single request, not a message across requests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That means <code>duplicatecheck=true<\/code> protects you from exactly one thing: submitting a comma-separated recipient list where the same number appears more than once. If your CRM export contains <code>919999999991,919999999992,919999999991<\/code>, the platform will collapse that to two recipients and you will be billed for two, not three. That is genuinely useful, and it is on by default, which is the right default.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What it does not do, and what you must not assume it does:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>It does not remember a request you made ten seconds ago and suppress an identical one now.<\/li>\n\n\n\n<li>It does not key on message content, so two different messages to the same number in one request are both sent.<\/li>\n\n\n\n<li>It does not span requests, processes, workers, or channels.<\/li>\n\n\n\n<li>It does not give you a stable identifier you can use to ask &#8220;did this already go out?&#8221;<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The published documentation does not specify any cross-request deduplication window for this parameter, and no such window is documented on the site. <strong>Treat <code>duplicatecheck<\/code> as within-request recipient hygiene with no documented cross-request memory.<\/strong> If your architecture depends on a time-windowed duplicate suppression behaviour, confirm the exact semantics with support before you rely on it in production, because an undocumented behaviour is not a contract, and building your correctness story on one is how you end up with a Sev 1 after a platform upgrade.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second, smaller trap in the same parameter. The documentation is internally inconsistent about capitalisation. The parameter table and the PHP and JSON samples use lowercase <code>duplicatecheck<\/code>, while the Python, cURL, Ruby, Go, Node.js, Swift and Google Apps Script samples on the same page use camelCase <code>duplicateCheck<\/code>. Form-encoded parameter names are frequently case-sensitive server-side. Since the default is <code>true<\/code> in either case, a silently ignored misspelled parameter is invisible until the day you explicitly set it to <code>false<\/code> and it does not take effect. If you are setting this parameter deliberately in either direction, verify with a two-identical-recipient test send against a number you control, and use whichever casing you observe working. Do not assume.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the correct mental model, expressed as a table:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Guard<\/th><th>Scope<\/th><th>Where it lives<\/th><th>What it stops<\/th><\/tr><\/thead><tbody><tr><td><code>duplicatecheck=true<\/code><\/td><td>One HTTP request<\/td><td>Platform<\/td><td>The same number appearing twice in one recipient list<\/td><\/tr><tr><td>Business key uniqueness<\/td><td>All time, all workers<\/td><td>Your database<\/td><td>The same logical message being submitted twice ever<\/td><\/tr><tr><td>Per-attempt claim TTL<\/td><td>Seconds to minutes<\/td><td>Redis or equivalent<\/td><td>Two workers racing on the same message right now<\/td><\/tr><tr><td>Queue job uniqueness<\/td><td>Queue lifetime<\/td><td>Celery, BullMQ, Laravel<\/td><td>The same job being enqueued twice while pending<\/td><\/tr><tr><td>Rate limit per recipient<\/td><td>Rolling window<\/td><td>Your application<\/td><td>A user hammering a resend button<\/td><\/tr><tr><td>DLR reconciliation<\/td><td>After the fact<\/td><td>Your database<\/td><td>Nothing. It detects, it does not prevent<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Only the second row is idempotency. Everything else is defence in depth.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-seven-layers\" class=\"wp-block-heading\">The Seven Layers That Generate Duplicates<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A duplicate message is a symptom. There are seven distinct places it can originate, and they need different fixes. Diagnosing the wrong layer is why teams add retry caps that do not help.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 1: The user<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A customer taps &#8220;Resend OTP&#8221; four times in six seconds. Every tap is a legitimate, distinct API call from your application&#8217;s point of view. No idempotency key helps here, because these are genuinely different requests with different intent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> a per-recipient rate limit with a visible countdown in the UI, plus a server-side floor. The OTP endpoint already returns a <code>retryAfter<\/code> field as a millisecond epoch on generate; surface it and enforce it server-side rather than trusting the client to respect its own countdown.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 2: The application entry point<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two HTTP requests arrive at your own API for the same business event, because an upstream service retried, a webhook fired twice, or a form was double-submitted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> derive the business key from the upstream event identifier, not from a random UUID generated at request time. A random UUID per request means two retries of the same event get two different keys, which is the same as having no key.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 3: The HTTP client library<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This one is invisible and catches almost everyone. <code>urllib3.Retry<\/code> in Python, when configured with <code>allowed_methods<\/code> including <code>POST<\/code>, will silently re-issue the request on a connection error or a 5xx. Your code sees one call. The platform sees two. Some HTTP client wrappers and service meshes do the same thing at the network layer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> set <code>max_retries=0<\/code> on the adapter for the send path specifically. Transport-layer retries are safe for idempotent reads such as the delivery report endpoint. They are never safe for a billable write. If you run a service mesh, disable retries for this route in the mesh config too.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import httpx\n\n# Send path: no transport-level retries, split timeouts.\nsend_client = httpx.Client(\n    timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0),\n    transport=httpx.HTTPTransport(retries=0),\n)\n\n# Read path: transport retries are fine here.\nreport_client = httpx.Client(\n    timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),\n    transport=httpx.HTTPTransport(retries=3),\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the <code>write<\/code> timeout being separate. A write timeout that fires after the body is partially flushed is ambiguous in exactly the same way a read timeout is. Only <code>ConnectTimeout<\/code> is unambiguously safe.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 4: The queue<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Celery with <code>acks_late=True<\/code> redelivers a task whose worker died mid-execution. BullMQ moves a job back to wait when its lock expires and the stalled-job checker picks it up. SQS redelivers after the visibility timeout. All of these are correct at-least-once behaviour and all of them will hand the same message to a second worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> the claim-before-send pattern below. Do not disable at-least-once redelivery to avoid duplicates, because the alternative is at-most-once, which silently drops messages, and a dropped OTP is worse than a duplicate one.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 5: The ambiguous timeout<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Covered above. This is the layer everyone thinks is the only layer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> claim before send, plus a reconciliation pass that resolves unknown-outcome records against the delivery report API rather than by re-sending.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 6: The batch boundary<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You submit 5,000 numbers in one request. The response times out. You have no idea whether zero, some, or all of them were accepted. If you re-submit the batch you may double-send all 5,000.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> treat a batch as a set of individually keyed messages, not as one atomic unit. Details in the batch section below.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Layer 7: The operator<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Below your application entirely. If a handset does not acknowledge receipt over the air, the operator&#8217;s SMSC may retransmit, and the subscriber sees the message twice. This is documented behaviour across the industry and it is not something an API parameter can fix.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> none available. This is why the last section of this article distinguishes between duplicates you prevent and duplicates you detect and absorb.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Layer<\/th><th>Duplicate mechanism<\/th><th>Guard<\/th><th>Prevents or detects<\/th><\/tr><\/thead><tbody><tr><td>1. User<\/td><td>Repeated resend taps<\/td><td>Per-recipient rate limit plus <code>retryAfter<\/code><\/td><td>Prevents<\/td><\/tr><tr><td>2. App entry<\/td><td>Upstream event replayed<\/td><td>Business key from upstream ID<\/td><td>Prevents<\/td><\/tr><tr><td>3. HTTP client<\/td><td>Transport-level auto-retry on POST<\/td><td><code>retries=0<\/code> on the send adapter<\/td><td>Prevents<\/td><\/tr><tr><td>4. Queue<\/td><td>at-least-once redelivery<\/td><td>Claim before send, unique index<\/td><td>Prevents<\/td><\/tr><tr><td>5. Network<\/td><td>Ambiguous read timeout<\/td><td>Claim before send plus reconciliation<\/td><td>Prevents<\/td><\/tr><tr><td>6. Batch<\/td><td>Whole-batch re-submission<\/td><td>Per-recipient keys, chunked sends<\/td><td>Prevents<\/td><\/tr><tr><td>7. Operator<\/td><td>SMSC retransmission over the air<\/td><td>None<\/td><td>Detect only<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-level-idempotency-keys\" class=\"wp-block-heading\">Two-Level Idempotency Keys<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The mistake that defeats most idempotency implementations is using one key for two jobs. You need two, and they have different lifetimes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Level 1: the business key<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This identifies the <strong>logical message<\/strong> and it must be derivable from the business event without any randomness. If the same event is processed a hundred times, the business key must come out identical a hundred times.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A good business key is a composite of the things that make the message unique in your domain:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{tenant_id}:{event_type}:{event_id}:{recipient_msisdn}:{template_id}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For an order shipment notification that would look like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>acme:order_shipped:ORD-88213:919999999999:1207161234567890123\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note what is not in there: no timestamp, no UUID, no attempt counter, no random salt. Any of those would make the key unique per attempt, which is precisely the failure mode.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note also what <em>is<\/em> in there: the template ID. Two messages to the same customer about the same order, one an order-shipped notice and one an out-for-delivery notice, are different logical messages and must produce different keys. Including the registered DLT template ID is a cheap way to get that separation for free, and it aligns your deduplication boundary with your compliance boundary. If you are not yet registering templates, the <a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DLT registration walkthrough<\/a> explains what you need before any of this applies to Indian traffic.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Store it hashed if the raw key is long or contains a phone number you would rather not index in plaintext:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import hashlib\n\ndef business_key(tenant, event_type, event_id, msisdn, template_id) -&gt; str:\n    raw = f\"{tenant}:{event_type}:{event_id}:{msisdn}:{template_id}\"\n    return hashlib.sha256(raw.encode(\"utf-8\")).hexdigest()\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A SHA-256 hex digest is 64 characters, fits comfortably in a <code>VARCHAR(64)<\/code> with a unique B-tree index, and does not leak the MSISDN into your logs or your index dumps.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Level 2: the attempt key<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This identifies one <strong>HTTP attempt<\/strong> at delivering the logical message. It is random, it is new on every attempt, and its only job is correlation in your logs and traces. It never controls whether a send happens.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import uuid\nattempt_key = uuid.uuid4().hex\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Log both on every attempt. When you are debugging a duplicate at 2am, the question you need to answer instantly is &#8220;did the same business key produce two attempts, and if so, did the first one already claim?&#8221; That is only answerable if both identifiers are on every log line.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why the platform&#8217;s transactionId is not your idempotency key<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Send SMS API returns a <code>transactionId<\/code> in the success response:<\/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}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That identifier is excellent for reconciliation and it is what you correlate delivery reports against. It is useless as an idempotency key, for one structural reason: <strong>you only receive it if the request succeeded.<\/strong> The exact scenario you are guarding against is the one where you never see the response. A key you cannot obtain during a failure cannot protect you during a failure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a general principle worth internalising. An idempotency key must be <strong>generated by the client, before the request, deterministically.<\/strong> Any identifier that originates server-side is a receipt, not a key.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Identifier<\/th><th>Generated by<\/th><th>Available before send<\/th><th>Stable across retries<\/th><th>Use for<\/th><\/tr><\/thead><tbody><tr><td>Business key<\/td><td>Client<\/td><td>Yes<\/td><td>Yes<\/td><td>Deduplication, the only correctness guard<\/td><\/tr><tr><td>Attempt key<\/td><td>Client<\/td><td>Yes<\/td><td>No<\/td><td>Tracing and log correlation<\/td><\/tr><tr><td><code>transactionId<\/code><\/td><td>Platform<\/td><td>No<\/td><td>No<\/td><td>DLR correlation, billing reconciliation<\/td><\/tr><tr><td><code>uuId<\/code> in DLR reports<\/td><td>Platform<\/td><td>No<\/td><td>No<\/td><td>Per-recipient report correlation<\/td><\/tr><tr><td>Queue job ID<\/td><td>Queue<\/td><td>Yes<\/td><td>Sometimes<\/td><td>Queue-level dedup only, never the sole guard<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-claim\" class=\"wp-block-heading\">The Claim-Before-Send State Machine<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the core pattern. It is six states and two rules.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>States:<\/strong> <code>pending<\/code> \u2192 <code>claimed<\/code> \u2192 <code>submitted<\/code> \u2192 <code>delivered<\/code> | <code>failed_terminal<\/code>, with <code>unknown<\/code> as a distinct terminal-pending state.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rule one: claim before you send.<\/strong> The claim is a durable write with a uniqueness guarantee that happens <em>before<\/em> the HTTP request opens. If the claim fails because the key already exists, you do not send, full stop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rule two: an ambiguous outcome moves to <code>unknown<\/code>, never back to <code>pending<\/code>.<\/strong> A record in <code>unknown<\/code> is resolved by reconciliation against delivery reports, not by re-sending.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second rule is the one teams get wrong. The instinct after a timeout is to put the message back on the queue. That instinct is the bug. A record whose outcome you do not know must not re-enter the send path until something external tells you it was not delivered.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The schema<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE outbound_message (\n    id                BIGSERIAL PRIMARY KEY,\n    business_key      VARCHAR(64)  NOT NULL,\n    tenant_id         VARCHAR(64)  NOT NULL,\n    channel           VARCHAR(16)  NOT NULL,      -- sms | whatsapp | voice | rcs\n    recipient         VARCHAR(20)  NOT NULL,\n    template_id       VARCHAR(32),\n    body_hash         CHAR(64)     NOT NULL,\n    state             VARCHAR(20)  NOT NULL DEFAULT 'pending',\n    attempt_count     INT          NOT NULL DEFAULT 0,\n    last_attempt_key  CHAR(32),\n    transaction_id    VARCHAR(64),\n    provider_status   VARCHAR(32),\n    claimed_at        TIMESTAMPTZ,\n    submitted_at      TIMESTAMPTZ,\n    resolved_at       TIMESTAMPTZ,\n    created_at        TIMESTAMPTZ  NOT NULL DEFAULT now(),\n\n    CONSTRAINT uq_outbound_business_key UNIQUE (business_key)\n);\n\nCREATE INDEX ix_outbound_unknown\n    ON outbound_message (state, submitted_at)\n    WHERE state = 'unknown';\n\nCREATE INDEX ix_outbound_txn\n    ON outbound_message (transaction_id)\n    WHERE transaction_id IS NOT NULL;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The unique constraint on <code>business_key<\/code> is doing all the work. Everything else is observability. Note the partial indexes: the <code>unknown<\/code> reconciliation sweep and the DLR correlation lookup are the only two queries that need to be fast at scale, and partial indexes keep them small.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Deliberately absent: a <code>channel<\/code> component inside <code>business_key<\/code>. That omission is intentional and the cross-channel section explains why.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why a unique index and not a SELECT-then-INSERT<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Because <code>SELECT<\/code> followed by <code>INSERT<\/code> is a race, and under exactly the conditions that produce duplicates, that race is not rare. Two workers that both pick up a redelivered job will both <code>SELECT<\/code> and find nothing, and both will <code>INSERT<\/code>. The database&#8217;s unique index is the only thing in your stack that can adjudicate that atomically. Let it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from sqlalchemy.exc import IntegrityError\n\ndef claim(session, key: str, **fields) -&gt; bool:\n    \"\"\"Return True if this caller owns the send, False if someone else already claimed it.\"\"\"\n    row = OutboundMessage(business_key=key, state=\"claimed\", claimed_at=utcnow(), **fields)\n    session.add(row)\n    try:\n        session.commit()\n        return True\n    except IntegrityError:\n        session.rollback()\n        return False\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That is the whole guard. <code>IntegrityError<\/code> on the unique constraint means somebody else got there first, and the correct response is to return without sending, not to raise.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Redis as a fast-path claim<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If your send volume makes a synchronous database write on the hot path uncomfortable, Redis <code>SET key value NX EX ttl<\/code> gives you the same atomicity with a fraction of the latency. It is a fast path, not a replacement, because Redis without AOF persistence can lose the claim on a restart.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def claim_fast(redis, key: str, attempt_key: str, ttl_seconds: int = 900) -&gt; bool:\n    return bool(redis.set(f\"idem:{key}\", attempt_key, nx=True, ex=ttl_seconds))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Pick the TTL deliberately. It must be longer than your worst-case end-to-end send latency including all retries, otherwise the claim expires while the first attempt is still in flight and a second worker sends. Fifteen minutes is a reasonable default for a path with a 15 second read timeout and three backoff attempts. If it is too short you get duplicates; if it is too long a genuinely failed send is blocked from legitimate reprocessing. When in doubt, err long and let the reconciliation sweep handle the stragglers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The correct architecture uses both: Redis for the hot-path claim, the database unique index as the durable backstop that survives a Redis flush.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-state-machine.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-state-machine-1024x584.webp\" alt=\"Diagram about The Claim-Before-Send State Machine\" class=\"wp-image-2641\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-state-machine-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-state-machine-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-state-machine-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-state-machine.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">The state transition table<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>From<\/th><th>Event<\/th><th>To<\/th><th>Notes<\/th><\/tr><\/thead><tbody><tr><td>(none)<\/td><td>Claim succeeds<\/td><td><code>claimed<\/code><\/td><td>Only now may you open the connection<\/td><\/tr><tr><td>(none)<\/td><td>Claim fails on unique violation<\/td><td>(no row created)<\/td><td>Return silently, do not send, do not error<\/td><\/tr><tr><td><code>claimed<\/code><\/td><td>HTTP 200 with <code>status: success<\/code><\/td><td><code>submitted<\/code><\/td><td>Persist <code>transactionId<\/code><\/td><\/tr><tr><td><code>claimed<\/code><\/td><td>HTTP 4xx with parseable error<\/td><td><code>failed_terminal<\/code><\/td><td>Do not retry without changing the request<\/td><\/tr><tr><td><code>claimed<\/code><\/td><td>Connect timeout or DNS failure<\/td><td><code>pending<\/code><\/td><td>Nothing was sent, safe to re-drive<\/td><\/tr><tr><td><code>claimed<\/code><\/td><td><strong>Read timeout or connection reset<\/strong><\/td><td><strong><code>unknown<\/code><\/strong><\/td><td><strong>Never back to <code>pending<\/code><\/strong><\/td><\/tr><tr><td><code>claimed<\/code><\/td><td>HTTP 5xx<\/td><td><code>unknown<\/code><\/td><td>Platform received it, outcome unclear<\/td><\/tr><tr><td><code>submitted<\/code><\/td><td>DLR reports delivered<\/td><td><code>delivered<\/code><\/td><td>Terminal<\/td><\/tr><tr><td><code>submitted<\/code><\/td><td>DLR reports permanent failure<\/td><td><code>failed_terminal<\/code><\/td><td>Terminal. See the error code reference<\/td><\/tr><tr><td><code>unknown<\/code><\/td><td>Reconciliation finds a matching DLR<\/td><td><code>submitted<\/code> or <code>delivered<\/code><\/td><td>Resolved without re-sending<\/td><\/tr><tr><td><code>unknown<\/code><\/td><td>Reconciliation finds nothing after the SLA window<\/td><td><code>pending<\/code><\/td><td>Only now is a re-drive safe<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That last row is where the judgement lives. How long you wait before declaring an <code>unknown<\/code> record genuinely un-submitted is a business decision, and it differs by message class. For an OTP, waiting is worse than a duplicate, so the window is short, perhaps 60 seconds, and you accept the duplicate risk. For a marketing message, waiting is free, so the window can be an hour. Encode this as a per-message-class policy, not a global constant.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"python-celery\" class=\"wp-block-heading\">Python: Celery, acks_late and the Duplicate Generator<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Celery is where most Python messaging pipelines acquire their duplicate problem, and the irony is that it happens while trying to fix a reliability problem.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/docs.celeryq.dev\/en\/stable\/userguide\/configuration.html\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">Celery configuration documentation<\/a> is the authority on these settings, and the default behaviour acknowledges a task the moment a worker picks it up. If the worker is killed mid-task, the message is gone and the SMS never goes out. That is at-most-once, and it silently drops OTPs, so teams set <code>acks_late=True<\/code>. Now the task is acknowledged only after it returns, and a worker killed mid-task causes the broker to redeliver to another worker. That is at-least-once, which is correct, and it means <strong>the send code will run twice for the same task, by design.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Stated plainly: <code>acks_late=True<\/code> without an idempotency guard is not a reliability improvement. It is a duplicate-message generator with better uptime.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># celery_app.py\nfrom celery import Celery\n\napp = Celery(\"messaging\", broker=\"redis:\/\/localhost:6379\/0\")\n\napp.conf.update(\n    task_acks_late=True,                # at-least-once, correct\n    task_reject_on_worker_lost=True,    # redeliver on SIGKILL, correct\n    worker_prefetch_multiplier=1,       # do not hoard tasks in a dying worker\n    task_acks_on_failure_or_timeout=False,\n    broker_transport_options={\"visibility_timeout\": 3600},\n)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>visibility_timeout<\/code>. If it is shorter than your task&#8217;s worst-case runtime, Redis redelivers a task that is still running, and you get two workers on the same message with no crash involved at all. Set it above your longest plausible task duration including all in-task retries.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And now the task itself, with the guard:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># tasks.py\nimport hashlib\nimport logging\nimport uuid\n\nimport httpx\nfrom celery import Task\nfrom celery.exceptions import Ignore\n\nfrom .celery_app import app\nfrom .db import session_scope\nfrom .models import OutboundMessage\n\nlog = logging.getLogger(__name__)\n\nSEND_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/send\"\n\n_client = httpx.Client(\n    timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0),\n    transport=httpx.HTTPTransport(retries=0),   # never auto-retry a billable POST\n)\n\n\ndef business_key(tenant, event_type, event_id, msisdn, template_id) -&gt; str:\n    raw = f\"{tenant}:{event_type}:{event_id}:{msisdn}:{template_id}\"\n    return hashlib.sha256(raw.encode(\"utf-8\")).hexdigest()\n\n\n@app.task(\n    bind=True,\n    name=\"messaging.send_sms\",\n    autoretry_for=(),          # deliberately empty: we control retries ourselves\n    max_retries=3,\n)\ndef send_sms(self: Task, *, tenant, event_type, event_id, msisdn,\n             template_id, entity_id, body, senderid):\n\n    key = business_key(tenant, event_type, event_id, msisdn, template_id)\n    attempt_key = uuid.uuid4().hex\n\n    log.info(\"send.start\", extra={\"business_key\": key, \"attempt_key\": attempt_key,\n                                  \"celery_task_id\": self.request.id,\n                                  \"delivery_count\": self.request.delivery_info})\n\n    # ---- CLAIM ----------------------------------------------------------\n    with session_scope() as s:\n        row = OutboundMessage(\n            business_key=key, tenant_id=tenant, channel=\"sms\",\n            recipient=msisdn, template_id=template_id,\n            body_hash=hashlib.sha256(body.encode()).hexdigest(),\n            state=\"claimed\", attempt_count=1, last_attempt_key=attempt_key,\n        )\n        s.add(row)\n        try:\n            s.flush()\n        except Exception:            # IntegrityError on uq_outbound_business_key\n            s.rollback()\n            existing = s.query(OutboundMessage).filter_by(business_key=key).one()\n            log.info(\"send.skipped_duplicate\",\n                     extra={\"business_key\": key, \"existing_state\": existing.state})\n            raise Ignore()           # not an error, not a retry, just stop\n\n    # ---- SEND -----------------------------------------------------------\n    payload = {\n        \"userid\": SGC_USERID,\n        \"password\": SGC_PASSWORD,\n        \"sendMethod\": \"quick\",\n        \"mobile\": msisdn,\n        \"msg\": body,\n        \"senderid\": senderid,\n        \"msgType\": \"text\",\n        \"dltEntityId\": entity_id,\n        \"dltTemplateId\": template_id,\n        \"duplicatecheck\": \"true\",\n        \"output\": \"json\",\n    }\n\n    try:\n        # data=, never json=. This endpoint is application\/x-www-form-urlencoded.\n        resp = _client.post(SEND_URL, data=payload)\n    except httpx.ConnectTimeout:\n        # Nothing was submitted. Safe to release the claim and re-drive.\n        _set_state(key, \"pending\")\n        raise self.retry(countdown=_backoff(self.request.retries))\n    except (httpx.ReadTimeout, httpx.WriteTimeout, httpx.RemoteProtocolError):\n        # AMBIGUOUS. The message may already be sent and billed.\n        _set_state(key, \"unknown\")\n        log.warning(\"send.ambiguous\", extra={\"business_key\": key})\n        raise Ignore()               # reconciliation resolves this, not a retry\n\n    if resp.status_code &gt;= 500:\n        _set_state(key, \"unknown\")\n        raise Ignore()\n\n    data = resp.json()\n    if data.get(\"status\") == \"success\":\n        _set_state(key, \"submitted\", transaction_id=str(data.get(\"transactionId\")),\n                   provider_status=str(data.get(\"statusCode\")))\n        return {\"transactionId\": data.get(\"transactionId\"),\n                \"invalidMobile\": data.get(\"invalidMobile\", \"\")}\n\n    # Structured error from the platform. Classify before deciding.\n    code = str(data.get(\"statusCode\", \"\"))\n    if code in PERMANENT_CODES:      # e.g. 188 template mismatch, invalid sender\n        _set_state(key, \"failed_terminal\", provider_status=code)\n        log.error(\"send.permanent_failure\",\n                  extra={\"business_key\": key, \"statusCode\": code,\n                         \"reason\": data.get(\"reason\")})\n        raise Ignore()\n\n    _set_state(key, \"pending\")\n    raise self.retry(countdown=_backoff(self.request.retries))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four things in there are load-bearing and worth calling out individually.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>autoretry_for=()<\/code> is deliberately empty.<\/strong> Celery&#8217;s automatic retry decorator does not know the difference between a connect timeout and a read timeout, so it will happily re-drive an ambiguous failure. Handle retries by hand on a billable path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>raise Ignore()<\/code> on a duplicate is not an error.<\/strong> If you raise a normal exception, Celery marks the task failed, your error rate alarms fire, and someone spends an afternoon investigating a system that worked perfectly. A suppressed duplicate is a success.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>ReadTimeout<\/code> goes to <code>unknown<\/code>, never to retry.<\/strong> This is rule two from the state machine, expressed in code. It is the single most important line in the file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>data=<\/code> not <code>json=<\/code>.<\/strong> The endpoint is form-encoded. Sending a JSON body to it produces a confusing authentication error rather than a content-type error, and engineers lose hours to it. This trap is covered in more depth in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python integration tutorial<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The reconciliation sweep<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>unknown<\/code> state is only useful if something drains it. Run this on a schedule.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from datetime import datetime, timedelta, timezone\n\nSLA = {\"otp\": timedelta(seconds=60),\n       \"transactional\": timedelta(minutes=10),\n       \"promotional\": timedelta(hours=1)}\n\n\n@app.task(name=\"messaging.reconcile_unknown\")\ndef reconcile_unknown():\n    now = datetime.now(timezone.utc)\n    with session_scope() as s:\n        rows = (s.query(OutboundMessage)\n                 .filter(OutboundMessage.state == \"unknown\")\n                 .filter(OutboundMessage.submitted_at &lt; now - timedelta(seconds=60))\n                 .limit(500).all())\n\n        for row in rows:\n            dlrs = fetch_dlr(mobile_no=row.recipient,\n                             fromdate=(row.claimed_at - timedelta(minutes=5)),\n                             todate=now)\n            match = _match_dlr(dlrs, row)\n            if match:\n                # It WAS submitted. Adopt the platform's record, do not re-send.\n                row.transaction_id = match&#91;\"msgId\"]\n                row.provider_status = match&#91;\"status\"]\n                row.state = \"delivered\" if match&#91;\"status\"] == \"DELIVRD\" else \"submitted\"\n                row.resolved_at = now\n                continue\n\n            window = SLA.get(row.message_class, timedelta(minutes=10))\n            if now - row.claimed_at &gt; window:\n                # No trace after the SLA window. Now a re-drive is defensible.\n                row.state = \"pending\"\n                row.attempt_count += 1\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>fetch_dlr<\/code> here wraps <code>GET https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> and a <code>mobileNo<\/code> filter, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">delivery report API page<\/a>. Matching on recipient plus a time window plus the <code>dltTemplateId<\/code> is usually enough to identify the record with confidence. If it is not, you have an observability gap that no amount of retry tuning will close.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"nodejs-bullmq\" class=\"wp-block-heading\">Node.js: BullMQ, Stalled Jobs and Redis Claims<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">BullMQ&#8217;s failure mode is different from Celery&#8217;s but arrives at the same place, and the <a href=\"https:\/\/docs.bullmq.io\/guide\/workers\/stalled-jobs\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">stalled jobs documentation<\/a> explains the mechanism precisely. A BullMQ worker holds a lock on an active job and renews it every <code>lockDuration \/ 2<\/code> milliseconds. If the worker&#8217;s event loop blocks, or the process dies, or Redis fails over, the lock expires. The stalled-job checker then moves the job back to <code>wait<\/code> and another worker picks it up. <code>maxStalledCount<\/code> defaults to 1, so this happens at least once before the job is failed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The subtle version of this bug has no crash in it at all. A synchronous CPU-bound operation on the event loop, such as rendering a large recipient file or a heavy regex, blocks lock renewal. The job is still running, but BullMQ believes it stalled and hands it to another worker. Now two workers are actively sending the same message concurrently. A guard with a TTL is the only thing that stops that.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ queue.js\nimport { Queue, Worker, UnrecoverableError } from 'bullmq';\nimport IORedis from 'ioredis';\nimport { createHash, randomUUID } from 'node:crypto';\nimport { setTimeout as sleep } from 'node:timers\/promises';\n\nconst connection = new IORedis({ maxRetriesPerRequest: null });\n\nexport const smsQueue = new Queue('sms', { connection });\n\nconst SEND_URL = 'https:\/\/unify.smsgateway.center\/SMSApi\/send';\n\nfunction businessKey({ tenant, eventType, eventId, msisdn, templateId }) {\n  return createHash('sha256')\n    .update(`${tenant}:${eventType}:${eventId}:${msisdn}:${templateId}`)\n    .digest('hex');\n}\n\n\/**\n * Atomic claim. Returns true only for the caller that wins.\n * TTL must exceed worst-case send duration including in-job backoff.\n *\/\nasync function claim(key, attemptKey, ttlSeconds = 900) {\n  const res = await connection.set(`idem:${key}`, attemptKey, 'NX', 'EX', ttlSeconds);\n  return res === 'OK';\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The worker:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const worker = new Worker('sms', async (job) =&gt; {\n  const key = businessKey(job.data);\n  const attemptKey = randomUUID().replace(\/-\/g, '');\n\n  \/\/ Claim in Redis first (fast path), then persist to Postgres (durable backstop).\n  const won = await claim(key, attemptKey);\n  if (!won) {\n    job.log(`duplicate suppressed for business_key=${key}`);\n    return { suppressed: true };          \/\/ resolve, do not throw\n  }\n\n  const durable = await db.claimDurable(key, job.data, attemptKey);\n  if (!durable) {\n    \/\/ Redis was flushed at some point; the database remembers. Trust the database.\n    return { suppressed: true, source: 'db' };\n  }\n\n  const body = new URLSearchParams({\n    userid: process.env.SGC_USERID,\n    password: process.env.SGC_PASSWORD,\n    sendMethod: 'quick',\n    mobile: job.data.msisdn,\n    msg: job.data.body,\n    senderid: job.data.senderid,\n    msgType: 'text',\n    dltEntityId: job.data.entityId,\n    dltTemplateId: job.data.templateId,\n    duplicatecheck: 'true',\n    output: 'json',\n  });\n\n  let res;\n  try {\n    res = await fetch(SEND_URL, {\n      method: 'POST',\n      headers: { 'content-type': 'application\/x-www-form-urlencoded' },\n      body,\n      \/\/ fetch in Node has NO default timeout. Without this the job hangs forever.\n      signal: AbortSignal.timeout(15_000),\n    });\n  } catch (err) {\n    if (err.name === 'TimeoutError' || err.name === 'AbortError') {\n      \/\/ AMBIGUOUS. Persist unknown, keep the Redis claim, do NOT throw.\n      await db.setState(key, 'unknown');\n      return { state: 'unknown' };\n    }\n    if (err.cause?.code === 'ENOTFOUND' || err.cause?.code === 'ECONNREFUSED') {\n      \/\/ Nothing left the process. Release the claim so a retry can proceed.\n      await connection.del(`idem:${key}`);\n      await db.setState(key, 'pending');\n      throw err;                      \/\/ let BullMQ retry with its backoff\n    }\n    await db.setState(key, 'unknown');\n    return { state: 'unknown' };\n  }\n\n  if (res.status &gt;= 500) {\n    await db.setState(key, 'unknown');\n    return { state: 'unknown' };\n  }\n\n  const data = await res.json();\n\n  if (data.status === 'success') {\n    await db.setState(key, 'submitted', {\n      transactionId: String(data.transactionId),\n      providerStatus: String(data.statusCode),\n    });\n    return { transactionId: data.transactionId, invalidMobile: data.invalidMobile };\n  }\n\n  if (PERMANENT_CODES.has(String(data.statusCode))) {\n    await db.setState(key, 'failed_terminal', { providerStatus: String(data.statusCode) });\n    \/\/ UnrecoverableError stops BullMQ retrying. Without it, a template mismatch\n    \/\/ is retried three times and fails identically three times.\n    throw new UnrecoverableError(`permanent ${data.statusCode}: ${data.reason}`);\n  }\n\n  await connection.del(`idem:${key}`);\n  await db.setState(key, 'pending');\n  throw new Error(`transient ${data.statusCode}: ${data.reason}`);\n}, {\n  connection,\n  concurrency: 8,\n  lockDuration: 60_000,        \/\/ must exceed worst-case job duration\n  stalledInterval: 30_000,\n  maxStalledCount: 1,\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three Node-specific points.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Returning rather than throwing on a suppressed duplicate.<\/strong> Throwing marks the job failed and, worse, triggers BullMQ&#8217;s own retry, which attempts the same suppressed send again. Resolve the job.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Releasing the claim on unambiguous failures only.<\/strong> On <code>ENOTFOUND<\/code> or <code>ECONNREFUSED<\/code> nothing was sent, so deleting the Redis key lets the retry proceed normally. On a <code>TimeoutError<\/code> the claim is deliberately left in place for its full TTL, because releasing it would allow a retry of a request that may already have been billed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>String(data.statusCode)<\/code>.<\/strong> The Send SMS API returns <code>statusCode<\/code> as a quoted string, while the WhatsApp API returns it as an unquoted number. Coerce at the boundary or your comparison silently fails on one of the two channels. This asymmetry and the rest of the Node failure surface are covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js integration tutorial<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Queue-level uniqueness is not enough<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">BullMQ supports a custom <code>jobId<\/code>, and setting it to your business key gives you free deduplication at enqueue time:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>await smsQueue.add('send', payload, {\n  jobId: businessKey(payload),\n  removeOnComplete: { age: 86400, count: 10000 },\n  attempts: 3,\n  backoff: { type: 'exponential', delay: 2000 },\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is worth doing, and it is not sufficient. <code>jobId<\/code> uniqueness only holds while the job exists in Redis. Once it completes and <code>removeOnComplete<\/code> evicts it, the same <code>jobId<\/code> can be added again and will run again. Queue-level uniqueness has the lifetime of the queue; business-key uniqueness has the lifetime of your data. Use both, and rely on the second.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"php-laravel\" class=\"wp-block-heading\">PHP and Laravel: ShouldBeUnique Is Not Idempotency<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Laravel&#8217;s <code>ShouldBeUnique<\/code> interface is frequently mistaken for an idempotency guarantee. It is not one, and the distinction matters.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ShouldBeUnique<\/code> acquires a cache lock keyed on <code>uniqueId()<\/code> when the job is dispatched, and releases it when the job finishes processing. It prevents a second copy of the job from being <strong>queued<\/strong> while the first is pending or running. It does nothing once the first job completes, and critically, it does nothing about a job that is retried after a timeout, because that is the same job instance, holding the same lock, running its send code a second time.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\nnamespace App\\Jobs;\n\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\DB;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Exception\\ConnectException;\nuse GuzzleHttp\\Exception\\RequestException;\n\nclass SendSms implements ShouldQueue, ShouldBeUnique\n{\n    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;\n\n    public int $tries = 3;\n    public int $uniqueFor = 900;      \/\/ lock TTL, seconds\n\n    public function __construct(\n        public string $tenant,\n        public string $eventType,\n        public string $eventId,\n        public string $msisdn,\n        public string $templateId,\n        public string $entityId,\n        public string $body,\n        public string $senderid,\n    ) {}\n\n    public function uniqueId(): string\n    {\n        return $this-&gt;businessKey();\n    }\n\n    public function businessKey(): string\n    {\n        return hash('sha256', implode(':', &#91;\n            $this-&gt;tenant, $this-&gt;eventType, $this-&gt;eventId,\n            $this-&gt;msisdn, $this-&gt;templateId,\n        ]));\n    }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the handler, where the real guard lives:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>    public function handle(Client $http): void\n    {\n        $key = $this-&gt;businessKey();\n        $attemptKey = bin2hex(random_bytes(16));\n\n        \/\/ ---- CLAIM: durable, atomic, survives cache flushes ----\n        $claimed = DB::transaction(function () use ($key, $attemptKey) {\n            try {\n                DB::table('outbound_message')-&gt;insert(&#91;\n                    'business_key'     =&gt; $key,\n                    'tenant_id'        =&gt; $this-&gt;tenant,\n                    'channel'          =&gt; 'sms',\n                    'recipient'        =&gt; $this-&gt;msisdn,\n                    'template_id'      =&gt; $this-&gt;templateId,\n                    'body_hash'        =&gt; hash('sha256', $this-&gt;body),\n                    'state'            =&gt; 'claimed',\n                    'attempt_count'    =&gt; 1,\n                    'last_attempt_key' =&gt; $attemptKey,\n                    'claimed_at'       =&gt; now(),\n                    'created_at'       =&gt; now(),\n                ]);\n                return true;\n            } catch (\\Illuminate\\Database\\UniqueConstraintViolationException $e) {\n                return false;\n            }\n        });\n\n        if (! $claimed) {\n            logger()-&gt;info('sms.duplicate_suppressed', &#91;'business_key' =&gt; $key]);\n            return;                    \/\/ NOT an exception. Just stop.\n        }\n\n        $form = &#91;\n            'userid'         =&gt; config('sgc.userid'),\n            'password'       =&gt; config('sgc.password'),\n            'sendMethod'     =&gt; 'quick',\n            'mobile'         =&gt; $this-&gt;msisdn,\n            'msg'            =&gt; $this-&gt;body,\n            'senderid'       =&gt; $this-&gt;senderid,\n            'msgType'        =&gt; 'text',\n            'dltEntityId'    =&gt; $this-&gt;entityId,\n            'dltTemplateId'  =&gt; $this-&gt;templateId,\n            'duplicatecheck' =&gt; 'true',\n            'output'         =&gt; 'json',\n        ];\n\n        try {\n            $res = $http-&gt;post('https:\/\/unify.smsgateway.center\/SMSApi\/send', &#91;\n                'form_params'     =&gt; $form,\n                'connect_timeout' =&gt; 5,\n                'timeout'         =&gt; 15,\n                'http_errors'     =&gt; false,   \/\/ keep JSON error bodies intact\n            ]);\n        } catch (ConnectException $e) {\n            \/\/ Connection never established. Nothing sent. Safe to release and retry.\n            $this-&gt;setState($key, 'pending');\n            $this-&gt;release($this-&gt;backoffSeconds());\n            return;\n        } catch (RequestException $e) {\n            \/\/ Request was written, response never completed. AMBIGUOUS.\n            $this-&gt;setState($key, 'unknown');\n            logger()-&gt;warning('sms.ambiguous', &#91;'business_key' =&gt; $key]);\n            return;                    \/\/ reconciliation resolves it\n        }\n\n        if ($res-&gt;getStatusCode() &gt;= 500) {\n            $this-&gt;setState($key, 'unknown');\n            return;\n        }\n\n        $data = json_decode((string) $res-&gt;getBody(), true) ?? &#91;];\n\n        if (($data&#91;'status'] ?? null) === 'success') {\n            $this-&gt;setState($key, 'submitted', &#91;\n                'transaction_id'  =&gt; (string) ($data&#91;'transactionId'] ?? ''),\n                'provider_status' =&gt; (string) ($data&#91;'statusCode'] ?? ''),\n                'submitted_at'    =&gt; now(),\n            ]);\n            return;\n        }\n\n        $code = (string) ($data&#91;'statusCode'] ?? '');\n        if (in_array($code, self::PERMANENT_CODES, true)) {\n            $this-&gt;setState($key, 'failed_terminal', &#91;'provider_status' =&gt; $code]);\n            $this-&gt;fail(new \\RuntimeException(\"permanent {$code}: \" . ($data&#91;'reason'] ?? '')));\n            return;\n        }\n\n        $this-&gt;setState($key, 'pending');\n        $this-&gt;release($this-&gt;backoffSeconds());\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The distinction between <code>$this-&gt;release()<\/code> and <code>$this-&gt;fail()<\/code> is worth internalising. <code>release()<\/code> puts the job back for another attempt and is correct for transient failures. <code>fail()<\/code> marks it permanently failed and stops the retry chain, which is what you want for a template mismatch or an invalid sender ID, because those will fail identically on every attempt and each attempt costs you a queue slot and a log line. Returning without either, which is what the ambiguous branch does, simply completes the job and leaves the durable record in <code>unknown<\/code> for reconciliation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ConnectException<\/code> versus <code>RequestException<\/code> in Guzzle maps almost exactly onto <code>ConnectTimeout<\/code> versus <code>ReadTimeout<\/code> in httpx. <code>ConnectException<\/code> is thrown when the connection could not be established, so nothing was transmitted. <code>RequestException<\/code> covers everything after that, and is therefore ambiguous. Do not catch them together. The broader PHP integration surface is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP integration tutorial<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Laravel mechanism<\/th><th>Prevents<\/th><th>Does not prevent<\/th><\/tr><\/thead><tbody><tr><td><code>ShouldBeUnique<\/code><\/td><td>Two dispatches while one is pending<\/td><td>Re-running after completion, or a retried instance re-sending<\/td><\/tr><tr><td><code>$tries<\/code> cap<\/td><td>Unbounded retry loops<\/td><td>Duplicates within the cap<\/td><\/tr><tr><td><code>$backoff<\/code><\/td><td>Thundering herd on recovery<\/td><td>Anything about identity<\/td><\/tr><tr><td>DB unique index on <code>business_key<\/code><\/td><td>Every duplicate submission, permanently<\/td><td>Operator-level retransmission<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"curl-raw-http-contract\" class=\"wp-block-heading\">cURL and the Raw HTTP Contract<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything above is language plumbing over one HTTP call. Here is the call, unadorned, so you can reason about exactly what crosses the wire.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST \\\n  https:\/\/unify.smsgateway.center\/SMSApi\/send \\\n  -H 'apikey: YOUR_API_KEY' \\\n  -H 'content-type: application\/x-www-form-urlencoded' \\\n  --connect-timeout 5 \\\n  --max-time 15 \\\n  --no-retry-all-errors \\\n  -d 'sendMethod=quick' \\\n  -d 'mobile=919999999999' \\\n  -d 'msg=Your order ORD-88213 has shipped.' \\\n  -d 'senderid=SGCTST' \\\n  -d 'msgType=text' \\\n  -d 'dltEntityId=1201159xxxxxxxxxxx' \\\n  -d 'dltTemplateId=1207161xxxxxxxxxxx' \\\n  -d 'duplicatecheck=true' \\\n  -d 'output=json'\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>--connect-timeout<\/code> and <code>--max-time<\/code> are separate flags for the same reason httpx separates them. Note also the deliberate absence of <code>--retry<\/code>. Adding <code>--retry 3<\/code> to a billable POST in a shell script is the shell equivalent of the urllib3 trap, and it is a common way for an operations runbook to quietly double-send during an incident.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A success 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}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There is no idempotency header on this endpoint. That is not a criticism, it is a design fact you have to build around: <strong>the deduplication responsibility sits with the client.<\/strong> Every code sample above exists because of that one sentence. If you want a request-level idempotency key honoured by the platform, ask <a href=\"https:\/\/www.smsgatewaycenter.com\/support\/\">support<\/a> whether it is on the roadmap rather than assuming an undocumented header works.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For interactive exploration, import the official <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS Postman collection<\/a> and set your credentials as collection variables. Do your duplicate testing there against a number you control before you write a line of application code. Send the same request twice, thirty seconds apart, and observe what actually arrives on the handset. That two-minute experiment will teach you more about your duplicate exposure than any amount of reading.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"batch-sends\" class=\"wp-block-heading\">Batch Sends: One transactionId, Many Recipients<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Batch sends are where idempotency reasoning most often breaks down, because the natural unit of the API call and the natural unit of your business logic are different sizes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Send SMS endpoint accepts a comma-separated recipient list in the <code>mobile<\/code> parameter. The documentation describes the endpoint as handling &#8220;single SMS or lists of comma-separated mobile numbers&#8221;, and the code samples confirm the format, showing values such as <code>mobile=919xxxxxxxxx,919999999998<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two properties of the response shape drive everything else:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There is one top-level <code>transactionId<\/code>.<\/strong> The documented success response carries a single <code>transactionId<\/code> field, not an array of per-recipient identifiers. That means you cannot map a specific recipient back to a specific identifier from the send response alone. Per-recipient correlation has to come from the delivery report API, where each report row carries <code>mobileNo<\/code>, <code>msgId<\/code> and <code>uuId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>There is an <code>invalidMobile<\/code> field.<\/strong> The response includes <code>invalidMobile<\/code>, empty in the success sample. This is the partial-rejection channel: numbers the platform could not accept are enumerated there while the overall <code>status<\/code> still reads <code>success<\/code>. Any batch send handler that checks only <code>status<\/code> and ignores <code>invalidMobile<\/code> will record a batch as fully sent when part of it was rejected, and those recipients will silently never receive anything.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>data = resp.json()\n\nif data.get(\"status\") == \"success\":\n    invalid = &#91;m for m in (data.get(\"invalidMobile\") or \"\").split(\",\") if m.strip()]\n    accepted = &#91;m for m in submitted_numbers if m not in invalid]\n\n    mark_submitted(accepted, transaction_id=str(data&#91;\"transactionId\"]))\n    mark_terminal(invalid, reason=\"invalidMobile\")   # do NOT retry these\n\n    if invalid:\n        log.warning(\"send.partial\", extra={\"accepted\": len(accepted),\n                                           \"invalid\": len(invalid)})\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Marking invalid numbers terminal rather than retryable matters. A malformed MSISDN will be rejected identically on every attempt, so retrying it burns queue capacity to no effect. Fix it upstream with proper E.164 normalisation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How to make batches idempotent<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The pattern is to keep the business key per recipient while keeping the HTTP call per batch:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Generate one business key per recipient, exactly as for a single send.<\/li>\n\n\n\n<li>Claim all of them in one database statement, using <code>INSERT ... ON CONFLICT DO NOTHING RETURNING business_key<\/code>. The returned set is the recipients you actually own.<\/li>\n\n\n\n<li>Build the comma-separated <code>mobile<\/code> value from only the returned set. Recipients that were already claimed are simply absent from the request.<\/li>\n\n\n\n<li>Send. On success, mark the returned set <code>submitted<\/code> against the shared <code>transactionId<\/code>, minus anything in <code>invalidMobile<\/code>.<\/li>\n\n\n\n<li>On an ambiguous timeout, mark <strong>all<\/strong> claimed recipients in that batch <code>unknown<\/code>. You do not know which subset was processed, so you must assume all of them might have been.<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>INSERT INTO outbound_message (business_key, tenant_id, channel, recipient,\n                              template_id, body_hash, state, claimed_at)\nSELECT k.business_key, k.tenant_id, 'sms', k.recipient,\n       k.template_id, k.body_hash, 'claimed', now()\nFROM   unnest($1::outbound_claim&#91;]) AS k\nON CONFLICT (business_key) DO NOTHING\nRETURNING business_key, recipient;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Step five is the one people resist, because marking 5,000 records <code>unknown<\/code> feels like giving up. It is not. It is refusing to guess. The reconciliation sweep will resolve every one of them from delivery reports within minutes, and the alternative, re-sending the batch, is a guaranteed 5,000-message duplicate event if the first submission actually landed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep batches small enough that an ambiguous outcome is cheap to reconcile. A batch of 500 that has to be reconciled is a minor query. A batch of 50,000 in <code>unknown<\/code> is an incident. Batch size is a reliability parameter, not just a throughput parameter.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Batch failure<\/th><th>What you know<\/th><th>Correct action<\/th><\/tr><\/thead><tbody><tr><td><code>status: success<\/code>, empty <code>invalidMobile<\/code><\/td><td>All accepted<\/td><td>Mark all <code>submitted<\/code><\/td><\/tr><tr><td><code>status: success<\/code>, populated <code>invalidMobile<\/code><\/td><td>Partial acceptance, listed numbers rejected<\/td><td>Mark accepted <code>submitted<\/code>, listed <code>failed_terminal<\/code><\/td><\/tr><tr><td>HTTP 4xx with error body<\/td><td>Nothing accepted<\/td><td>Fix the request, then re-drive<\/td><\/tr><tr><td>Connect timeout<\/td><td>Nothing sent<\/td><td>Release claims, re-drive<\/td><\/tr><tr><td>Read timeout<\/td><td>Unknown for every recipient<\/td><td>Mark all <code>unknown<\/code>, reconcile<\/td><\/tr><tr><td>HTTP 5xx<\/td><td>Unknown for every recipient<\/td><td>Mark all <code>unknown<\/code>, reconcile<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"cross-channel\" class=\"wp-block-heading\">Cross-Channel Deduplication Across SMS and WhatsApp<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Multi-channel fallback is the feature most likely to turn a well-behaved pipeline into a duplicate machine, because the failure it is designed to handle is the exact failure that leaves you uncertain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The scenario: you send an order update over WhatsApp using <code>POST https:\/\/unify.smsgateway.center\/WAApi\/send<\/code>. It times out. Your fallback logic fires and sends the same content over SMS via <code>SMSApi\/send<\/code>. That one succeeds. Then the original WhatsApp request, which was in fact accepted, delivers. The customer gets the same message on two channels.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is structural, and it is why the schema earlier deliberately excludes <code>channel<\/code> from <code>business_key<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The business key identifies the notification, not the delivery attempt on a particular channel.<\/strong> One order-shipped event for one customer produces one business key, regardless of how many channels you try. The <code>channel<\/code> column records which channel won. It is not part of the identity.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CHANNEL_ORDER = &#91;\"whatsapp\", \"sms\"]\n\ndef notify(event, recipient):\n    key = business_key(event.tenant, event.type, event.id,\n                       recipient.msisdn, event.template_id)   # channel-free\n\n    if not claim(key):\n        return \"already_handled\"\n\n    for channel in CHANNEL_ORDER:\n        outcome = attempt(channel, key, event, recipient)\n\n        if outcome.state == \"submitted\":\n            set_channel(key, channel)\n            return \"sent_via_\" + channel\n\n        if outcome.state == \"unknown\":\n            # The message may already be live on this channel.\n            # Falling through would be a cross-channel duplicate.\n            set_state(key, \"unknown\", channel=channel)\n            return \"unknown_no_fallback\"\n\n        if outcome.state == \"failed_terminal\" and not outcome.channel_specific:\n            # e.g. the recipient number itself is invalid. No channel will help.\n            return \"terminal\"\n\n        # Only a clean, channel-specific, definite failure falls through.\n\n    set_state(key, \"failed_terminal\")\n    return \"exhausted\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The critical branch is the <code>unknown<\/code> one. Most fallback implementations treat &#8220;I did not get a success response&#8221; as &#8220;it failed, try the next channel&#8221;. That is wrong, and it is wrong specifically for timeouts, which are the most common trigger for fallback in the first place. <strong>Fall back only on definite, channel-specific failure.<\/strong> An ambiguous outcome must stop the chain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is more conservative than the platform&#8217;s own default. The knowledge base entry on <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-are-trigger-error-codes-in-multi-channel-fallback\/\">trigger error codes in multi-channel fallback<\/a> states that &#8220;By default, any failure on a channel (except the last) can trigger the next step. You can narrow this to specific delivery error codes only.&#8221; Narrow it. Enumerate the delivery error codes that genuinely mean the channel cannot deliver, such as an unreachable handset or an absent subscriber, and exclude anything that could mean &#8220;accepted but unconfirmed&#8221;. The platform-side <a href=\"https:\/\/www.smsgatewaycenter.com\/multi-channel-fallback\/\">multi-channel fallback<\/a> feature and your application-side fallback should not both be active on the same message, or you get two independent fallback engines racing.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">WhatsApp has an additional trap<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WhatsApp fails differently from SMS, and it fails in a way that is hostile to timeout-based reasoning. An SMS that cannot be delivered produces a delivery report with a cause code, eventually. A WhatsApp message sent to a number that is not on WhatsApp, or outside a valid session window, can be accepted by the API and then simply never arrive, with no timeout and no error at your layer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That asymmetry has two consequences for deduplication. First, you cannot use silence as evidence of non-delivery on WhatsApp the way you partially can on SMS. Second, no WhatsApp-specific delivery report endpoint is documented on the site, so the reconciliation pattern that works for SMS does not have a confirmed equivalent for WhatsApp. Until that is confirmed, treat a WhatsApp <code>unknown<\/code> as unresolvable by automation and route it to a manual queue or let it expire, rather than building a reconciler on an assumed endpoint. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-reports\/\">WhatsApp reports page<\/a> is the place to check for current capability before you build.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The parsing asymmetry matters here too. <code>SMSApi\/send<\/code> returns <code>\"statusCode\": \"200\"<\/code> as a quoted string. <code>WAApi\/send<\/code> returns <code>\"statusCode\": 200<\/code> as a number, alongside <code>messageId<\/code> rather than <code>transactionId<\/code> and <code>description<\/code> rather than <code>reason<\/code>. A shared response parser must normalise all three or your cross-channel state machine will mis-classify one channel silently.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def normalise(channel: str, data: dict) -&gt; dict:\n    if channel == \"sms\":\n        return {\"ok\": data.get(\"status\") == \"success\",\n                \"id\": str(data.get(\"transactionId\") or \"\"),\n                \"code\": str(data.get(\"statusCode\") or \"\"),\n                \"reason\": data.get(\"reason\") or \"\"}\n    return {\"ok\": data.get(\"status\") == \"success\",\n            \"id\": str(data.get(\"messageId\") or \"\"),\n            \"code\": str(data.get(\"statusCode\") or \"\"),\n            \"reason\": data.get(\"description\") or \"\"}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The full channel-selection reasoning, including why WhatsApp is a poor primary OTP channel, is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-vs-sms-api-2025\/\">SMS API versus WhatsApp Business API comparison<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Channel<\/th><th>Success identifier<\/th><th>statusCode type<\/th><th>Ambiguity signal<\/th><th>Reconcilable<\/th><\/tr><\/thead><tbody><tr><td>SMS via <code>SMSApi\/send<\/code><\/td><td><code>transactionId<\/code><\/td><td>Quoted string<\/td><td>Read timeout, 5xx<\/td><td>Yes, via <code>SMSApi\/reports\/status<\/code><\/td><\/tr><tr><td>WhatsApp via <code>WAApi\/send<\/code><\/td><td><code>messageId<\/code><\/td><td>Unquoted number<\/td><td>Read timeout, 5xx, plus silent non-delivery<\/td><td>Not confirmed publicly<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"otp-is-a-special-case\" class=\"wp-block-heading\">OTP Is a Special Case<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every rule above holds for OTP with one inversion: <strong>for OTP, the cost of a missing message exceeds the cost of a duplicate one.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A customer who receives two identical OTP codes is mildly confused for four seconds. A customer who receives none abandons the signup. So the reconciliation SLA for OTP should be aggressive, measured in tens of seconds, and you should accept a higher duplicate rate in exchange for a lower drop rate. Encode that as policy rather than treating all traffic identically.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But OTP has its own duplicate mechanic that has nothing to do with timeouts, and it is a security problem rather than a UX one. If a user, or a script, triggers OTP generation repeatedly, each call produces a new code. Beyond the direct cost, which is real if someone is running an SMS pumping attack against your signup endpoint, you get a race where the user reads code A from their first message while your system now only accepts code C.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">OTP generate endpoint<\/a> returns <code>retryAfter<\/code> alongside <code>createTime<\/code> and <code>expiryTime<\/code>, all as millisecond epochs, with <code>statusCode<\/code> 300 on success. Use it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import time\n\nresp = client.post(\"https:\/\/unify.smsgateway.center\/SMSApi\/otp\", data={\n    \"userid\": USERID, \"password\": PASSWORD,\n    \"sendMethod\": \"generate\",\n    \"mobile\": msisdn,\n    \"msgType\": \"text\",\n    \"msg\": \"Your verification code is $otp$. Valid for 5 minutes.\",\n    \"medium\": \"sms\",\n    \"codeType\": \"num\",\n    \"codeLength\": 6,\n    \"codeExpiry\": 300,\n    \"senderid\": SENDERID,\n    \"output\": \"json\",\n})\ndata = resp.json()\n\nif str(data.get(\"statusCode\")) == \"300\":\n    retry_after_ms = int(data&#91;\"retryAfter\"])\n    seconds = max(0, (retry_after_ms - int(time.time() * 1000)) \/\/ 1000)\n    store_resend_floor(msisdn, seconds)      # enforce server-side, not just in the UI\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that <code>msg<\/code> carries the literal <code>$otp$<\/code> placeholder and the platform substitutes the generated code. You never generate the code yourself on this endpoint, which also means you never have a client-side value to key on. Deduplicate on the <strong>verification session<\/strong>, not the code: one business key per signup attempt, not per OTP request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rate limiting the resend is not optional. Enforce it server-side against the MSISDN with a rolling window, because a client-side countdown is trivially bypassed and SMS pumping fraud specifically targets endpoints that only enforce it in the UI. Product context for the OTP flow is on the <a href=\"https:\/\/www.smsgatewaycenter.com\/otp-sms-india\/\">OTP SMS page<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reconciling-with-delivery-reports\" class=\"wp-block-heading\">Reconciling With Delivery Reports<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Reconciliation is what makes the <code>unknown<\/code> state safe. Without it, <code>unknown<\/code> is just a place records go to die.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">delivery report API<\/a> is a <code>GET<\/code> or <code>POST<\/code> to <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, a <code>fromdate<\/code> and <code>todate<\/code>, a <code>pageLimit<\/code>, and optional <code>mobileNo<\/code>, <code>uuId<\/code> or <code>startCursor<\/code> filters. The response contains a <code>reports_dlrList<\/code> array whose rows carry <code>mobileNo<\/code>, <code>msgId<\/code>, <code>uuId<\/code>, <code>status<\/code>, <code>cause<\/code>, <code>submitTime<\/code>, <code>deliveryTime<\/code>, <code>dltTemplateId<\/code>, <code>cost<\/code>, <code>length<\/code>, <code>network<\/code> and <code>senderName<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note that this is a <strong>read<\/strong> endpoint, so unlike the send path it is safe to configure transport-level retries against it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def reconcile_one(row) -&gt; str | None:\n    \"\"\"Return the resolved state, or None if no matching report exists yet.\"\"\"\n    reports = fetch_dlr(\n        mobile_no=row.recipient,\n        fromdate=(row.claimed_at - timedelta(minutes=2)).strftime(\"%Y-%m-%d %H:%M:%S\"),\n        todate=(row.claimed_at + timedelta(minutes=30)).strftime(\"%Y-%m-%d %H:%M:%S\"),\n        page_limit=100,\n    )\n\n    for r in reports:\n        if r.get(\"mobileNo\") != row.recipient:\n            continue\n        if row.template_id and r.get(\"dltTemplateId\") != row.template_id:\n            continue\n        submit = parse_ts(r.get(\"submitTime\"))\n        if not (row.claimed_at - timedelta(minutes=2) &lt;= submit\n                &lt;= row.claimed_at + timedelta(minutes=5)):\n            continue\n\n        row.transaction_id = r.get(\"msgId\")\n        row.provider_status = r.get(\"status\")\n        row.cost = Decimal(str(r.get(\"cost\", \"0\")))\n        return \"delivered\" if r.get(\"status\") == \"DELIVRD\" else \"submitted\"\n\n    return None\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three filters compose the match: recipient, template ID, and a submit-time window bracketing the claim. Any one alone is too loose. Recipient alone will match an unrelated message to the same customer. The time window alone will match a different customer. Together they are usually decisive, and where they are not, you have discovered that your traffic pattern sends multiple identical messages to the same person in the same minute, which is a finding worth acting on independently.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>cost<\/code> field is what makes this a billing control and not just a state cleanup. Summing <code>cost<\/code> across reconciled rows and comparing against your own submitted count gives you a running duplicate-spend figure. The cost model behind that number is worked through in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">bulk SMS pricing breakdown<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Webhooks are faster but do not replace polling<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Registering a webhook via <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code> gets reports pushed to you in near real time, which shrinks the <code>unknown<\/code> window from minutes to seconds. Details are on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-webhook\/\">create webhook page<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">real-time DLR webhooks article<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two cautions. First, your webhook receiver must itself be idempotent, because a webhook that is not acknowledged fast enough will be redelivered, and a receiver that blindly appends state on every call will corrupt its own record. Persist raw, deduplicate on the report identifier, then process asynchronously. Second, a webhook you never receive is indistinguishable from a message that was never submitted, so the polling reconciler stays as the backstop. Push for latency, poll for correctness.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The meaning of each DLR status value is catalogued in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/dlr-status-meaning\/\">DLR status meanings reference<\/a>, and the general model of delivery reporting is covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-delivery-reports-dlr\/\">understanding delivery reports<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"duplicates-you-cannot-prevent\" class=\"wp-block-heading\">Duplicates You Cannot Prevent<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Honesty about the boundary of your control is part of building a credible system.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Below your application, below the platform, the operator&#8217;s SMSC performs its own retransmission. If a handset does not acknowledge receipt over the air, because it was in a lift, or switching cells, or had a full inbox, the SMSC may retransmit. The subscriber sees the message twice. Your logs show one submission, one delivery report, one charge. Nothing in your stack is wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Vendor documentation across the industry describes this behaviour: where a device cannot signal that it received an SMS, the operator treats it as undelivered and retries delivery until it receives a successful acknowledgement. That mechanism is invisible and unreachable from any API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second category, also outside your control: aggregator-level retries on a route between the platform and the operator. Multi-hop delivery paths can retransmit at a hop you have no visibility into.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Practical consequences:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Do not chase a zero-duplicate SLA.<\/strong> It is unachievable, and pursuing it leads to over-conservative logic that drops real messages.<\/li>\n\n\n\n<li><strong>Instrument what you can attribute.<\/strong> Distinguish &#8220;duplicate submissions from our systems&#8221;, which should be zero and is your responsibility, from &#8220;duplicate handset receipts&#8221;, which will not be zero and is not.<\/li>\n\n\n\n<li><strong>Design message content to survive duplication.<\/strong> A message that reads naturally when received twice costs you nothing. &#8220;Your OTP is 449182&#8221; is fine twice. &#8220;You have 1 new message&#8221; followed by an identical copy is confusing but harmless. Anything phrased as an incremental update, such as &#8220;your balance has been reduced by 500&#8221;, is actively dangerous when duplicated, because the reader cannot tell whether it happened once or twice. State absolutes, not deltas.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That last point is a content design rule that engineers rarely think of as their problem, and it is the cheapest duplicate mitigation available. Write templates that are idempotent to read.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"exactly-once-is-a-myth\" class=\"wp-block-heading\">Exactly-Once Is a Myth, Effectively-Once Is Achievable<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">It is worth being precise about the theory, because the vocabulary shapes the architecture.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Exactly-once <strong>delivery<\/strong> is impossible in a distributed system. This is not an engineering limitation to be solved with a better library, it is a consequence of <a href=\"https:\/\/en.wikipedia.org\/wiki\/Two_Generals%27_Problem\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">the two generals problem<\/a>: two parties communicating over an unreliable channel cannot reach certain agreement, no matter how many messages they exchange. The sender can never know whether the last acknowledgement was lost, and neither can the receiver. Any protocol that appears to achieve exactly-once delivery has moved the uncertainty somewhere else, not eliminated it. The argument is laid out at length in <a href=\"https:\/\/bravenewgeek.com\/you-cannot-have-exactly-once-delivery\/\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">You Cannot Have Exactly-Once Delivery<\/a>, which remains the clearest treatment of it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What is achievable is exactly-once <strong>processing<\/strong>, sometimes called effectively-once, and it is built from two ordinary parts:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>At-least-once delivery.<\/strong> The transport retries until acknowledged. This produces duplicates and that is fine and expected.<\/li>\n\n\n\n<li><strong>An idempotent receiver.<\/strong> The processing side recognises repeats by identity and executes the side effect only once.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Everything in this article is an instance of that pattern. Celery <code>acks_late<\/code> and BullMQ stalled-job recovery supply part one. The business key and its unique constraint supply part two. Neither alone is correct. Together they are.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason this framing matters practically is that it tells you where to spend effort. Teams often try to make part one perfect, tuning timeouts and retry counts to make duplicates rare. That work has diminishing returns and never reaches zero. The same effort spent on part two reaches correctness, because a system with an idempotent receiver is indifferent to how many duplicates part one generates. Make the transport aggressive and the receiver strict, not the other way around.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The one caveat specific to messaging: your side effect is a physical SMS arriving on a handset, which you cannot roll back. In a database system, an idempotent receiver can detect a duplicate and undo. Here, once submitted, it is submitted, billed, and en route. That is why the guard must be <strong>before<\/strong> the call, not after it. In most idempotency literature the check happens on receipt of a request; here it must happen before emission of one.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Guarantee<\/th><th>Achievable<\/th><th>What it costs<\/th><th>Right for messaging<\/th><\/tr><\/thead><tbody><tr><td>At-most-once<\/td><td>Yes<\/td><td>Silent message loss<\/td><td>No. A dropped OTP is worse than a duplicate<\/td><\/tr><tr><td>At-least-once<\/td><td>Yes<\/td><td>Duplicates<\/td><td>Yes, as the transport layer<\/td><\/tr><tr><td>Exactly-once delivery<\/td><td>No<\/td><td>Not available at any price<\/td><td>Not a design option<\/td><\/tr><tr><td>Effectively-once processing<\/td><td>Yes<\/td><td>A unique index and discipline<\/td><td>Yes. This is the target<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-steps-to-an-idempotent-send-path\" class=\"wp-block-heading\">Ten Steps to an Idempotent Send Path<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Use this as the implementation order. Each step is independently shippable and each one reduces duplicate exposure on its own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 1. Define the business key.<\/strong> Write down, in one line, what makes a message unique in your domain. Compose the key from a tenant identifier, an event type, an event identifier, the recipient MSISDN and the template identifier. Verify by hand that processing the same event twice produces byte-identical keys.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 2. Add the table and the unique constraint.<\/strong> Create <code>outbound_message<\/code> with <code>UNIQUE (business_key)<\/code>. The constraint is the guarantee. Everything else in the table is observability.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 3. Move the claim before the HTTP call.<\/strong> Find every place your code calls the send endpoint and insert the claim immediately above it, inside the same function. If the claim and the send can be separated by a queue boundary, a network hop, or a conditional, they will eventually diverge.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 4. Turn off transport-level retries on the send path.<\/strong> Set <code>retries=0<\/code> in httpx, <code>max_retries=0<\/code> on a <code>requests<\/code> adapter, remove <code>--retry<\/code> from shell scripts, and disable route-level retries in any service mesh in front of the call. Leave them enabled on the delivery report endpoint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 5. Split your timeout handling.<\/strong> Catch connect failures separately from read failures. Connect failures go to <code>pending<\/code>. Read failures, write failures, connection resets and 5xx responses go to <code>unknown<\/code>. Never let an ambiguous outcome reach the retry branch.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 6. Classify platform errors into permanent and transient.<\/strong> Pull the authoritative list from <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/responsecodes<\/code>, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API response error code list page<\/a>. Permanent codes such as a template mismatch must terminate, not retry.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 7. Build the reconciliation sweep.<\/strong> A scheduled job that reads <code>unknown<\/code> records older than a per-message-class SLA, queries the delivery report API filtered by recipient and time window, and resolves each record to <code>submitted<\/code>, <code>delivered<\/code>, or back to <code>pending<\/code> only after the SLA window has elapsed with no trace.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 8. Make the batch path per-recipient.<\/strong> Claim recipients individually with <code>ON CONFLICT DO NOTHING RETURNING<\/code>, build the comma-separated <code>mobile<\/code> value from the returned set only, and parse <code>invalidMobile<\/code> on every success response.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 9. Make fallback conservative.<\/strong> Remove <code>channel<\/code> from the business key. Fall back only on definite channel-specific failure. An ambiguous outcome stops the chain rather than advancing it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Step 10. Instrument and alarm.<\/strong> Emit a counter for suppressed duplicates, a gauge for the depth of the <code>unknown<\/code> backlog, and a counter for reconciliation outcomes split by resolution type. A rising suppressed-duplicate count means an upstream system started replaying. A growing <code>unknown<\/code> backlog means reconciliation is falling behind and you are one incident away from a mass re-send decision made under pressure.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix: Which Guard Belongs Where<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Situation<\/th><th>Business key<\/th><th>Redis claim<\/th><th>DB unique index<\/th><th>Queue job ID<\/th><th>Rate limit<\/th><th>Reconciler<\/th><\/tr><\/thead><tbody><tr><td>Single transactional SMS, low volume<\/td><td>Required<\/td><td>Optional<\/td><td>Required<\/td><td>Nice to have<\/td><td>No<\/td><td>Required<\/td><\/tr><tr><td>High-volume transactional, many workers<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td>No<\/td><td>Required<\/td><\/tr><tr><td>OTP generate<\/td><td>Per session<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td><strong>Required<\/strong><\/td><td>Required, short SLA<\/td><\/tr><tr><td>Promotional campaign, one shot<\/td><td>Required<\/td><td>Optional<\/td><td>Required<\/td><td>Required<\/td><td>No<\/td><td>Required, long SLA<\/td><\/tr><tr><td>Batch of 500 recipients<\/td><td>Per recipient<\/td><td>Optional<\/td><td>Required<\/td><td>Per batch<\/td><td>No<\/td><td>Required<\/td><\/tr><tr><td>Cross-channel fallback<\/td><td>Channel-free<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td>No<\/td><td>Required per channel<\/td><\/tr><tr><td>Scheduled or recurring sends<\/td><td>Per occurrence<\/td><td>Optional<\/td><td>Required<\/td><td>Required<\/td><td>No<\/td><td>Required<\/td><\/tr><tr><td>Inbound webhook triggering a send<\/td><td>From upstream event ID<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td>Per source<\/td><td>Required<\/td><\/tr><tr><td>User-initiated resend button<\/td><td>Per session<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td><strong>Required<\/strong><\/td><td>Required<\/td><\/tr><tr><td>WhatsApp session message<\/td><td>Channel-free<\/td><td>Required<\/td><td>Required<\/td><td>Required<\/td><td>No<\/td><td>Manual, no confirmed DLR endpoint<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The pattern across the rows: the database unique index is required everywhere, the reconciler is required everywhere, and everything else varies by volume and by whether a human can trigger the send.<\/p>\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\">Twenty-two items. Every one of them corresponds to a duplicate incident someone has already had.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Business key is deterministic and contains no timestamp, UUID or random component.<\/li>\n\n\n\n<li>Business key includes the template identifier, so different notifications about the same entity do not collide.<\/li>\n\n\n\n<li>Business key excludes the channel, so fallback cannot double-notify.<\/li>\n\n\n\n<li>A unique constraint exists on <code>business_key<\/code> in the database, not only in application code.<\/li>\n\n\n\n<li>The claim happens before the HTTP call, in the same function, with no intervening await on an unrelated resource.<\/li>\n\n\n\n<li>A unique-constraint violation is handled as a normal control-flow outcome, not an exception that fails the job.<\/li>\n\n\n\n<li>Transport-level retries are disabled on the send path.<\/li>\n\n\n\n<li>Transport-level retries are enabled on the delivery report path.<\/li>\n\n\n\n<li>Connect timeouts and read timeouts are caught in separate branches.<\/li>\n\n\n\n<li>Read timeouts, write timeouts, connection resets and 5xx responses all route to <code>unknown<\/code>.<\/li>\n\n\n\n<li>No code path moves a record from <code>unknown<\/code> directly back into the send queue without reconciliation.<\/li>\n\n\n\n<li>Queue visibility timeout or lock duration exceeds worst-case job runtime including in-job backoff.<\/li>\n\n\n\n<li>Redis claim TTL exceeds worst-case end-to-end send duration.<\/li>\n\n\n\n<li>A durable claim exists behind the Redis claim, so a cache flush cannot open the gate.<\/li>\n\n\n\n<li><code>invalidMobile<\/code> is parsed on every success response and those recipients are marked terminal.<\/li>\n\n\n\n<li>Batch ambiguity marks every recipient in the batch <code>unknown<\/code>, not a guessed subset.<\/li>\n\n\n\n<li>Permanent error codes are enumerated from the platform&#8217;s own error code list and terminate rather than retry.<\/li>\n\n\n\n<li>Fallback advances only on definite, channel-specific failure.<\/li>\n\n\n\n<li>Response parsing normalises <code>transactionId<\/code> versus <code>messageId<\/code> and quoted versus unquoted <code>statusCode<\/code> across channels.<\/li>\n\n\n\n<li>Server-side rate limiting exists on any endpoint a user can trigger repeatedly, enforced against the MSISDN.<\/li>\n\n\n\n<li>The reconciliation sweep runs on a schedule, is monitored, and has an alarm on backlog depth.<\/li>\n\n\n\n<li>Message templates are worded as absolute statements rather than incremental deltas, so a duplicate handset receipt is harmless.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes-that-cause-duplicate-sends\" class=\"wp-block-heading\">Ten Mistakes That Cause Duplicate Sends<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. Generating the idempotency key inside the retry loop.<\/strong> A fresh UUID per attempt is the same as no key. The key must be derived from the event, outside the loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. Using the platform&#8217;s <code>transactionId<\/code> as the idempotency key.<\/strong> You only receive it on success, and the failure you are guarding against is the one where you receive nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. Assuming <code>duplicatecheck<\/code> suppresses repeated requests.<\/strong> It removes duplicate mobile numbers within a single request. It is documented that way and nothing broader is documented.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. Catching all timeouts in one branch.<\/strong> <code>except Timeout<\/code> in Python and a single <code>catch<\/code> in JavaScript both collapse the safe case and the ambiguous case into one, and the handler inevitably retries both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. Leaving <code>urllib3.Retry<\/code> with <code>allowed_methods<\/code> including POST.<\/strong> Your code sees one call, the platform sees three, and nothing in your logs reveals it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>6. Enabling <code>acks_late<\/code> without an idempotency guard.<\/strong> This converts a worker crash from a lost message into a guaranteed duplicate. It is still the right setting; it just needs the guard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>7. Treating <code>ShouldBeUnique<\/code> or a BullMQ <code>jobId<\/code> as the whole answer.<\/strong> Both have the lifetime of the queue entry. Business identity outlives the queue.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>8. Re-sending an entire batch after a timeout.<\/strong> You do not know which subset landed. Mark them all unknown and reconcile.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>9. Falling back to the next channel on a timeout.<\/strong> Timeouts are the most common fallback trigger and the least valid one, because the first channel may have succeeded.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>10. Writing state after the send instead of before it.<\/strong> If the process dies between the send and the write, the message is out and your records say it never happened. The next run sends it again. Claim first, always.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is message idempotency?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Message idempotency means that submitting the same logical message any number of times produces exactly one delivered message and one charge. It is achieved by giving each logical message a deterministic identity, claiming that identity in a store with a uniqueness guarantee before any API call is made, and refusing to send when the claim fails.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does the SMS API have an idempotency key header?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No idempotency key header is documented for the Send SMS endpoint. Deduplication is the client&#8217;s responsibility, which is why the patterns in this article place the guard in your own database or cache rather than relying on a platform-side mechanism.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does the duplicatecheck parameter actually do?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation describes it as &#8220;Enable to remove duplicate mobile numbers. Default is true.&#8221; It removes repeated mobile numbers from the recipient list within a single request. It is not documented as suppressing repeated requests over time, so do not model it as a retry guard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there a time window in which duplicatecheck suppresses repeats?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No such window is documented on the site. Treat the parameter as within-request recipient deduplication with no documented cross-request memory. If your design needs a time-windowed suppression behaviour, confirm the exact semantics with support before depending on it, because undocumented behaviour is not a contract.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>If my request times out, was the SMS sent?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You cannot tell from the timeout. A connect timeout means nothing was submitted. A read timeout means the request was fully transmitted and may have been accepted, billed and delivered. Treat read timeouts as unknown and resolve them by querying the delivery report API, not by re-sending.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Am I charged for a message that timed out?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the platform accepted and submitted it, yes. SMSGatewayCenter states that &#8220;Credits are non-refundable once SMS is successfully submitted to the operator&#8221; and that the per-SMS rate is deducted from your wallet while sending. Billing is tied to submission, not to your receipt of a response.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What should the idempotency key be made of?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A tenant identifier, an event type, an event identifier, the recipient MSISDN and the template identifier, concatenated and hashed. No timestamps, no UUIDs, no attempt counters. If the same business event produces two different keys, you have no guard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use the transactionId as the idempotency key?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. It is issued by the platform in the success response, so it is unavailable in exactly the failure scenario you need protection for. Use it for delivery report correlation and billing reconciliation instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does a batch send return one transactionId or one per recipient?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented success response carries a single top-level <code>transactionId<\/code> field. To correlate a specific recipient with a specific outcome, use the delivery report API, where each row carries <code>mobileNo<\/code>, <code>msgId<\/code> and <code>uuId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I detect that part of a batch was rejected?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Parse the <code>invalidMobile<\/code> field in the response. Numbers the platform could not accept appear there while the overall <code>status<\/code> may still read <code>success<\/code>. Code that checks only <code>status<\/code> will record rejected recipients as sent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does Celery&#8217;s acks_late cause duplicate messages?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">By design, yes. It provides at-least-once delivery, which means a task interrupted mid-execution is redelivered and the send code runs again. That is the correct setting, because the alternative silently drops messages, but it must be paired with an idempotency guard or it becomes a duplicate generator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is Laravel&#8217;s ShouldBeUnique enough to prevent duplicate SMS?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. It prevents a second copy of the job from being queued while the first is pending or running. It does not prevent a retried job instance from executing its send code twice, and it stops protecting once the job completes. Add a unique index on the business key in the database.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I fall back from WhatsApp to SMS when the WhatsApp request times out?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No. A timeout does not mean the WhatsApp message failed, and falling back on an ambiguous outcome is how cross-channel duplicates happen. Fall back only on definite, channel-specific failures identified by delivery error code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I ever achieve exactly-once delivery?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Not as a delivery guarantee. Exactly-once delivery is impossible in a distributed system because of the two generals problem. What you can build is effectively-once processing: at-least-once transport plus an idempotent send path. That is what every production messaging system actually implements.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why do customers sometimes receive a message twice even though my logs show one submission?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The operator&#8217;s SMSC can retransmit a message when the handset does not acknowledge receipt over the air. That happens below the API and cannot be prevented by any parameter. Track duplicate submissions from your own systems separately from duplicate handset receipts, and write templates that read sensibly if received twice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Build it!<\/h2>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Build it once, correctly.<\/strong> Every pattern in this guide runs against the same live endpoints: <code>SMSApi\/send<\/code> for SMS, <code>WAApi\/send<\/code> for WhatsApp, <code>SMSApi\/reports\/status<\/code> for reconciliation. <a href=\"https:\/\/unify.smsgateway.center\/signup\/\" target=\"_blank\" rel=\"noreferrer noopener\">Create a free SMSGatewayCenter account<\/a> to get your API key, import the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS Postman collection<\/a>, and test your duplicate handling against a real gateway before it matters. If you are wiring up an India-facing pipeline, our team can review your DLT template mapping and route configuration alongside it. <a href=\"https:\/\/www.smsgatewaycenter.com\/support\/\">Talk to support<\/a>.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A client-side timeout does not mean your message was not sent. Because billing fires at submission, a blind retry bills you twice and delivers twice. This is the full engineering guide to two-level idempotency keys, claim-before-send state machines, Celery and BullMQ redelivery semantics, cross-channel deduplication across the SMS and WhatsApp APIs, and why the duplicatecheck parameter does not do what most teams assume it does.<\/p>\n","protected":false},"author":118,"featured_media":2639,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2099,2080,2095,2098,2097,2094,2093,2096,1177,2015,481,632],"class_list":["post-2638","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-bullmq","tag-celery","tag-distributed-systems","tag-dlr-reconciliation","tag-duplicate-sms","tag-exactly-once-delivery","tag-idempotency-key","tag-message-idempotency","tag-otp-delivery","tag-retry-logic","tag-sms-api","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2638","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=2638"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2638\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2639"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2638"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2638"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2638"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}