Messaging APIs fail the way all networked things fail, but the consequence is different. A duplicate write to your database is a bug. A duplicate text is a customer wondering whether you are broken or rude.
The three outcomes, and only one is safe to retry
- Definite failure — a 4xx with a clear reason: bad number, unsubscribed, malformed body. Do not retry. Fix the input or drop it.
- Definite success — a 2xx with a message ID. Record the ID. Do not retry.
- Unknown — a timeout, a connection reset, a 5xx. The message may or may not have gone out. This is the dangerous one, and it is where idempotency keys earn their keep.
A timeout is not a failure
The request may have reached the provider, been accepted, and delivered a message before your connection dropped. Retrying blindly sends it twice. Treat unknown as unknown and let an idempotency key resolve it.
Send an idempotency key on every request
Derive it from something stable about the intent — not from a random value, or a retry generates a fresh key and defeats the point.
import { createHash } from "node:crypto"; // Stable across retries: same intent → same key → provider dedupes for you.function idempotencyKey(msg: ScheduledMessage) { return createHash("sha256") .update(`${msg.id}:${msg.recipient}:${msg.body}`) .digest("hex") .slice(0, 32);} export async function sendOnce(msg: ScheduledMessage) { return fetch(ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Idempotency-Key": idempotencyKey(msg), "Content-Type": "application/json", }, body: JSON.stringify({ to: msg.recipient, text: msg.body }), });}Back off, with jitter, and give up
Exponential backoff spreads load off a struggling provider. Jitter stops your whole queue retrying in lockstep. A cap stops a permanently broken message retrying forever.
const MAX_ATTEMPTS = 5; export async function sendWithRetry(msg: ScheduledMessage) { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { const res = await sendOnce(msg); if (res.ok) return markSent(msg, (await res.json()).id); // 4xx that is not rate limiting: retrying will not help. if (res.status >= 400 && res.status < 500 && res.status !== 429) { return markFailed(msg, `permanent: ${res.status}`); } } catch { // Network-level failure: outcome genuinely unknown, so fall through // to a retry. The idempotency key protects us from duplicating. } if (attempt === MAX_ATTEMPTS) return markFailed(msg, "exhausted"); const backoff = 2 ** attempt * 1000; const jitter = backoff * 0.3 * hashFraction(msg.id, attempt); await sleep(backoff + jitter); }}Respect Retry-After
Providers rate-limit deliberately to keep numbers healthy. When you get a 429, the response usually tells you how long to wait. Honour it rather than applying your own backoff — hammering through a rate limit is how a number gets flagged.
Alert on the right thing
Not send failures, which are usually a handful of bad numbers. Alert on a drop in the delivered rate from your webhooks, because that is what silent carrier filtering looks like: sends succeeding, deliveries quietly not happening. See deliverability.
And build a dead-letter queue. Messages that exhaust their retries should land somewhere a human looks, not vanish into a log line nobody reads.
Next step
Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.