Skip to content

Idempotency & retries

You POST /ng/v1/sendsms. The connection times out.

Did the message send? You genuinely don’t know. Three things could have happened:

  1. The request never reached us. Nothing sent.
  2. We accepted it and the response was lost on the way back. It sent.
  3. 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.

Generate a unique key per logical send, and put it in a header:

Terminal window
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.

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 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_content will still be missing content in eight seconds.
EndpointIdempotency
POST /ng/v1/sendsmsIdempotency-Key header
POST /email/ng/v1/messagesIdempotency-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

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.