To prevent duplicate SMS from your API, generate one idempotency key for each message when you create the send job, and reuse that same key on every retry of that job. On Ready, the key goes in an Idempotency-Key header on POST /v1/messages, and a retried request that carries the same key never sends twice.

The duplicate is usually not a provider bug. Your code asked a question, did not hear the answer, and asked again. The rest of this post covers why that happens, what it costs, the one header that fixes it, and how to decide whether a failed send is safe to retry.

Why the same text goes out twice

The timeout trap

A send request has three steps. Your server sends the request, the provider accepts it and queues the text toward the carrier, and the provider sends the response back to you. A failure in any of those steps looks the same from your side: an exception, a timeout, or a dropped connection.

If the failure happened in the first step, the text never left, and retrying is correct. If it happened in the third step, the text is already on its way to the handset. Your HTTP client cannot tell these two cases apart. A 10-second timeout fires whether the request died before it arrived or the response died on the way back.

Most retry logic assumes the first case. The second case is how a customer gets "Your order #1042 has shipped" twice, 30 seconds apart.

The other usual causes

Timeouts get the blame, but a few other patterns produce the same result:

  • Queue redelivery. Most job queues deliver at least once. If a worker sends the text and then crashes before it acknowledges the job, the queue gives the job to another worker, and that worker sends the text again.
  • Double submits. A user clicks "Send" twice, or a form posts twice on a slow connection.
  • Overlapping crons. A scheduled reminder job runs longer than its interval, and the next run picks up the same rows.
  • Re-run handlers. Your own event handler, such as "order shipped → send text," fires again because the upstream system re-emitted the event.

An idempotency key handles all of these, with one condition: the key has to identify the message, not the HTTP attempt. That distinction gets its own section below.

What a duplicate actually costs

The direct cost is easy to calculate. Every segment you send is billed. On Ready Standard that is $0.0075/segment plus a $0.0045 carrier fee per segment.

Worked example (assumptions stated): a nightly appointment-reminder job sends 5,000 single-segment texts. A deploy restarts the workers halfway through, the queue redelivers everything that was in flight, and the whole batch goes out twice.

  • Duplicate sends: 5,000
  • Per-segment rate: 5,000 × $0.0075 = $38
  • Carrier fees: 5,000 × $0.0045
  • Replies such as "why did I get this twice?" are billed like sent texts, at the same per-segment rate plus the carrier fee.

That total leaves out the compliance surcharge. Scrubbing and landline validation each cost $0.003 per segment extra if you have switched them on for the account. Both are off by default.

The bigger cost is harder to put a number on. A duplicate looks like spam to the person who receives it, and annoyed recipients reply STOP. Ready honors the opt-out and propagates it, so that contact cannot be texted again across any campaign. That is correct behavior, and it also means a buggy retry can permanently remove reachable customers from your list. If you run SMS for other businesses through sub-accounts, the duplicate also appears on their bill. That reconciliation is unpleasant, and we walk through it in the segment-count audit post.

How to prevent duplicate SMS with the Idempotency-Key header

Ready's send endpoint accepts an Idempotency-Key header, and any UUID works as the value. If you retry a request with the same key, the text is not sent a second time. Here is a Node example that creates the key once and reuses it on every attempt:

import { randomUUID } from "node:crypto";

// Create the key ONCE, when the job is created, and store it with the job.
// Every retry of this job (including after a worker crash) reuses it.
const job = {
  idempotencyKey: randomUUID(),
  to: "+12125550199",
  body: "Your order #1042 has shipped.",
};

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function sendWithRetry(job, maxAttempts = 4) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    let res;
    try {
      res = await fetch("https://api.tryready.com/v1/messages", {
        method: "POST",
        headers: {
          "X-API-Key": process.env.READY_API_KEY,
          "Idempotency-Key": job.idempotencyKey,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          to: job.to,
          body: job.body,
          consent_attested: true,
        }),
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      // Timeout or dropped connection: the text may or may not have gone out.
      // Retrying with the SAME key is safe either way.
      if (attempt === maxAttempts) throw err;
      await sleep(2 ** attempt * 500);
      continue;
    }

    if (res.ok) return res;
    const err = await res.json().catch(() => ({}));

    // Still running under this key (after a timeout above), or rate limited (a 429 is never stored):
    // wait, then retry with the SAME key.
    if ((res.status === 429 || err.code === "idempotency_in_progress") && attempt < maxAttempts) {
      await sleep(2 ** attempt * 2000);
      continue;
    }

    // Other answers are stored under this key, so retrying with the same key only
    // replays them. A 500 send_failed or a 503 was not sent: the job needs a NEW key.
    // A 504 or a code ending in OUTCOME_UNKNOWN may have gone out: check first.
    // A 4xx means the request itself needs fixing.
    throw Object.assign(new Error(`Send failed with status ${res.status}`), { status: res.status, code: err.code });
  }
}

Some notes on the request itself. to is E.164, and a 10-digit US number is treated as +1. consent_attested: true is your promise that this recipient agreed to receive texts, so set it only when that is true. The exact error and response shapes are in the messages API docs. Check them there rather than hardcoding assumptions about the response body.

If you are still wiring up the basics, such as auth, numbers, and your first send, start with our SMS API integration walk-through and the quickstart, then come back here.

Where the key should come from

This is where most idempotency setups fail. The header is easy to add. Picking the right value is the hard part.

Wrong: generating a fresh UUID inside the function that makes the HTTP call. Each attempt gets a new key, so the provider sees each attempt as a new message, and the duplicate goes out anyway. This bug is common because it looks correct in code review.

Right: generate the key at the moment the message comes into existence as a business fact, and persist it next to the job. A few patterns that work:

  1. Store it on the job row. When you enqueue "send shipping notice for order 1042," write the UUID into the job record in the same transaction. Every worker that picks up the job reads the same key.
  2. Derive it from the event. Use a name-based UUID (UUIDv5) built from something like order-1042:shipping-notice. If the upstream system fires the event twice, both runs produce the same key, and that also covers the "re-run handler" case.
  3. One key per intended message, not per recipient list. A shipping notice and a delivery notice for the same order are two different messages and need two different keys.

Never reuse a key for a message with different content. If you fix a typo and want to send the corrected text, that is a new message and needs a new key. The messages docs describe how a reused key is handled.

When a retry is safe (and when it isn't)

The rule of thumb: retry when the outcome is unknown, and do not retry when the outcome is known. Here is how that plays out on Ready:

What happenedCould the text have gone out?Retry?
Request timed out or connection droppedYesYes, with the same key
409 idempotency_in_progressYes, it is still being sentYes. Wait a few seconds and retry with the same key
500 send_failed or a 503No. Not sent, not chargedYes, with a new key. The same key replays the error
504, or a code ending in OUTCOME_UNKNOWNPossiblyCheck GET /v1/messages first, or retry with the same key: it answers 409 idempotency_in_progress until the first attempt finishes, then replays its real result. A new key can send twice
Success responseYesNo
Request rejected as malformed (bad number format, missing field)NoNo. Fix the data and send it as a corrected job
Recipient has opted out, is blocklisted, or is a known landlineNo. Refused up front, not chargedNever
Sent during the recipient's quiet hours202: held, sends when the window opens (only if that is within 6 hours). 409 QUIET_HOURS_BLOCK: not sent, not queued202: no, it is already queued. 409: resend after next_allowed_at, with a new key

Things that look like failures but aren't

Two rows in that table cause a lot of accidental duplicates, because they look like "it didn't send."

Quiet hours. Ready holds an API text sent during the recipient's quiet hours (based on their area) and sends it when the window opens, but only if that is within 6 hours; otherwise the send is refused with 409 QUIET_HOURS_BLOCK and must be resent. A held text is charged only when it sends. If a support agent sees that a 7 AM reminder hasn't arrived and clicks "resend" from your admin panel, the recipient gets two texts once the window opens. Build your UI to show held texts as held. We cover the timezone side of this in the quiet-hours post.

Refusals. Opt-outs, numbers on your Ban list, and known landlines are refused before sending and are not charged. Retrying one of these will never succeed, and retrying an opt-out in particular is a compliance problem waiting to happen. You can check a number ahead of time with GET /v1/opt-outs/:phone or GET /v1/validate/:phone. The rules behind STOP handling are in our opt-out guide.

Bulk sends and chunking

For the same text to many numbers, POST /v1/messages/bulk takes an array in to:

  • Send an Idempotency-Key on bulk calls too. One bulk request is one logical send, so it gets one key.
  • If you split a large list into chunks, keep the chunking deterministic. Sort the list the same way every run and give each chunk its own stored key, such as campaign-88:chunk-0007. If a retry rebuilds the chunks differently, the keys no longer match the contents, and protection is lost for whatever moved between chunks.
  • Record which chunks returned success. A restarted job should resume from the first unconfirmed chunk and resend that chunk with its original key, unless that chunk answered a 500 or a 503: it was not sent, and its original key only replays the error, so give it a new one.

The bulk messages docs cover the details of the bulk request.

What idempotency doesn't fix

Be clear about where this protection ends:

  • It only covers retries of the same logical message. If your system creates two separate jobs for one event, each with its own random key, both texts go out. The fix belongs upstream, in the form of event-derived keys or a uniqueness constraint on the job table.
  • It does not replace your own records. Keep a local log of what you intended to send and the key you used. GET /v1/messages lets you read back what was sent and page through it with before_id, which helps when reconciling after an incident.
  • It is not specific to Ready. Any provider can hit the timeout trap. If you are evaluating a raw CPaaS such as Twilio or Telnyx, check whether its send endpoint accepts an idempotency key. If it doesn't, you need a dedupe table on your side before every send. Those providers can be a fine choice on per-segment cost. Just go in knowing which part of this problem you'll own.

FAQ

Why did my SMS API send the same text twice? Almost always because your code retried a request that had actually succeeded. The provider accepted the text, but the response timed out or the connection dropped, so your code assumed it had failed and sent it again. Queue redelivery and double-clicked send buttons cause the same thing.

What is an idempotency key in an SMS API? It is a unique value you attach to a send request so the provider can recognize retries of the same message. On Ready you pass any UUID in the Idempotency-Key header, and a retried request with the same key never sends twice.

Should I generate a new idempotency key on every retry? No. A new key on each retry turns every attempt into a new message, and that is exactly how duplicates happen. Generate the key once, when the message is created, and store it with the job so every retry reuses it.

Is it safe to retry an SMS send after a timeout? Yes, as long as you retry with the same idempotency key. Without the key, a timeout retry is a gamble, because the first request may already have delivered the text. With the key, the worst case is a harmless no-op.

Am I charged for texts that were never sent? On Ready, texts to opted-out or numbers on your Ban list are refused up front and not charged, and so are known landlines. A text held for quiet hours is charged only when it actually sends. A duplicate that does go out is billed like any other text, which is why the key matters.

The short version

Duplicates come from retries that cannot tell "failed" apart from "succeeded, but I didn't hear back." The fix has three parts. Create one idempotency key per intended message, store it with the job, and send it on every attempt. Retry timeouts with that same key; a 500 send_failed or a 503 was not sent and needs a new key. Never retry refusals (except a 409 QUIET_HOURS_BLOCK, after its next_allowed_at) or held texts, and make sure your UI shows held texts as held.

If you want to try this against a real endpoint, sign up and use your first 20 texts, which are one-to-one sends, to test the retry path before you register. Point the code above at your own phone and force a timeout. To send, you add a card and get a number, which is $2 a month with the first month charged when you get it.