Idempotency & retries
The problem
Section titled “The problem”You POST /ng/v1/sendsms. The connection times out.
Did the message send? You genuinely don’t know. Three things could have happened:
- The request never reached us. Nothing sent.
- We accepted it and the response was lost on the way back. It sent.
- We accepted it, sent it, and then the connection dropped. It sent.
From your side these are identical: no response. If you retry, in cases 2 and 3 your customer gets the message twice and you pay twice. If you don’t retry, in case 1 they get nothing.
An Idempotency-Key makes the retry safe, so you can always retry.
The fix
Section titled “The fix”Generate a unique key per logical send, and put it in a header:
curl -X POST https://api.9bits.net/ng/v1/sendsms \ -H "Authorization: Bearer $NINEBITS_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f14e45f-ea0d-4b9e-9c1f-2a7b3c4d5e6f" \ -d '{ "from": "9bits", "to": ["2348020000000"], "content": "Your code is 481920." }'If a request with that key already succeeded, we return the original response
— same reference_id, same everything — and send nothing new.
So the retry is free. Same key, same result, no duplicate message, no duplicate charge.
Choosing a key
Section titled “Choosing a key”Use something that identifies the business action, not the HTTP attempt.
// Good — derived from the thing you're doing.const key = `otp:${userId}:${otpAttemptId}`;const key = `order-confirmation:${orderId}`;
// Also fine — generated once, stored, reused across retries.const key = order.idempotencyKey ??= crypto.randomUUID();
// Wrong — new key every attempt. This does nothing for you.const key = crypto.randomUUID(); // ← inside the retry loopA correct retry loop
Section titled “A correct retry loop”async function sendSMS(payload, idempotencyKey) { const delays = [1000, 2000, 4000, 8000]; // exponential backoff
for (let attempt = 0; ; attempt++) { let res; try { res = await fetch('https://api.9bits.net/ng/v1/sendsms', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NINEBITS_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, // same key on every attempt }, body: JSON.stringify(payload), signal: AbortSignal.timeout(10_000), }); } catch (networkError) { // Timeout or connection failure — the dangerous case. Safe to retry // *only* because of the idempotency key. if (attempt >= delays.length) throw networkError; await sleep(delays[attempt]); continue; }
if (res.ok) return res.json();
const { error } = await res.json();
// 4xx (except 429) will fail identically forever. Don't waste attempts. if (res.status < 500 && res.status !== 429) { throw new Error(`${error.type}/${error.code}: ${error.message}`); }
if (attempt >= delays.length) throw new Error(error.code);
const retryAfter = Number(res.headers.get('Retry-After')) * 1000; await sleep(retryAfter || delays[attempt]); }}Three things this gets right:
- The key is passed in, generated once by the caller — not minted per attempt.
- Network errors are retried. That’s the whole point; without the key you couldn’t safely do this.
- Permanent failures aren’t retried. A
missing_contentwill still be missing content in eight seconds.
Which endpoints support it
Section titled “Which endpoints support it”| Endpoint | Idempotency |
|---|---|
POST /ng/v1/sendsms | ✅ Idempotency-Key header |
POST /email/ng/v1/messages | ✅ Idempotency-Key header. A replay is flagged with Idempotent-Replayed: true on the response. |
POST /voice/v1/voice/sendvoice | ❌ Not supported — see below |
POST /vas/ng/v1/service/two-way/sendsms | ❌ Not supported |
Reading the status endpoint
Section titled “Reading the status endpoint”Rather than retrying a send you’re unsure about, you can look it up — if you kept
the reference_id. But note the ordering problem: a timeout means you may never
have received a reference_id, which is exactly when you need it most.
That’s why the idempotency key matters: it’s a reference you chose, before the request, so it survives any failure.