Reliability
Idempotency and safe retries
Send an Idempotency-Key header on POST /v1/sms/send and /v1/sms/bulk. A repeat with the same key and the identical body returns the original response and sends nothing. Keys belong to your workspace and last 24 hours. Transactional sends require one.
Why it matters
A request that times out may or may not have been accepted, and a timeout cannot tell you which. Without an idempotency key, retrying it can send, and charge, the same messages twice. With one, the retry returns the first answer instead.
How to use it
curl -X POST https://api.sendozi.com/v1/sms/send \
-H "Authorization: Bearer $SENDOZI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-48291-shipped" \
-d '{
"sender": "YourBrand",
"recipient": "08012345678",
"message": "Your order 48291 has shipped.",
"route": "promotional"
}'- Accepted on
POST /v1/sms/sendandPOST /v1/sms/bulk. Other endpoints ignore it. - Up to 255 characters. Longer returns 400 invalid_request.
- Optional for promotional sends, required for transactional ones (400 transactional_idempotency_required).
What happens on a repeat
| Situation | Result |
|---|---|
| First use of the key | Processed normally, and the response stored |
| Same key, identical body, same endpoint | The original 202 response is returned with the same batch_id and message_id. Nothing is sent or charged again |
| Same key, any difference in the body or endpoint | 409 idempotency_conflict |
| Same key while the first request is still running | 409 idempotency_conflict; wait and retry |
| Same key after the first request was refused | Processed as new: a refused request releases its key |
| Same key more than 24 hours after first use | Processed as new |
- Keys are per workspace. Two workspaces using the same key never collide.
- The replayed response has a new top-level
request_id; thedatais the original. - A refused request (a validation error, an empty wallet, an unapproved Sender ID) releases its key, so you can fix the problem and retry with the same key.
Choosing a key
Build the key from your own identifier for the thing being sent: an order id, a login attempt id, a campaign id. A value generated fresh for each attempt defeats the mechanism, because every retry looks like a new request.
| Good | Bad | Why |
|---|---|---|
| order-48291-shipped | crypto.randomUUID() per attempt | A new key per attempt means every retry sends again |
| login-8f21c4-attempt-2 | login-8f21c4 | Two codes for the same login are two events and need two keys |
| campaign-2026-09-24-part-3 | campaign | Reused across sends, so the next send gets the old answer or a 409 |
A retry loop
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
export async function sendWithRetry(payload, idempotencyKey, attempts = 4) {
const body = JSON.stringify(payload); // serialised once, resent unchanged
for (let attempt = 1; ; attempt += 1) {
let response;
try {
response = await fetch("https://api.sendozi.com/v1/sms/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDOZI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
if (attempt >= attempts) throw error; // network error or timeout: retry
}
if (response) {
const result = await response.json();
if (result.success) return result.data;
const inFlight = response.status === 409 && /still being processed/.test(result.error.message);
if (!RETRYABLE.has(response.status) && !inFlight) {
throw new Error(`${result.error.code}: ${result.error.message}`);
}
if (attempt >= attempts) throw new Error(result.error.code);
const wait = Number(response.headers.get("retry-after"));
if (wait > 0) {
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
}
await new Promise((r) => setTimeout(r, 2 ** attempt * 500 + Math.random() * 250));
}
}
await sendWithRetry(payload, `order-${order.id}-shipped`);Retry network errors, timeouts, 429 and 5xx. Do not retry other 4xx responses: they are answers about the request, and will be the same next time. See errors for each code.
Frequently asked questions
- How long does Sendozi remember an idempotency key?
- 24 hours from its first use, within your workspace. After that the same key is treated as a new request.
- What happens if I reuse an idempotency key with a different message?
- The request returns 409 idempotency_conflict and nothing is sent. The key is tied to the exact body it was first used with.
- Why did my retry return idempotency_conflict when I sent the same data?
- The body was not byte-for-byte identical, for example because the JSON was rebuilt in a different order or included a timestamp. Serialise the body once and resend the same string.
- Does a refused request use up the idempotency key?
- No. A refused request releases its key, so you can correct the problem and retry with the same key.
Related reading
Sending
Sending SMS
POST /v1/sms/send and /v1/sms/bulk: every request field, the response, how recipients are cleaned, limits, the delivery window, and exactly how a send is priced and charged.
Reliability
Errors and the response envelope
The Sendozi response envelope, every error code the API returns with its HTTP status and fix, which errors are safe to retry, Retry-After, and how request_id traces a request.
Reliability
Limits, large sends and pagination
The Sendozi request limits, what 429 and Retry-After mean, how a send of up to 1,000,000 recipients is accepted and paced out to the networks, and how cursor pagination works.
Sending
Transactional SMS: OTPs, alerts and receipts
What a transactional send needs: a Transactional Sender ID, one recipient, your own message and an Idempotency-Key. No templates to register. With a working OTP example.