Reliability
Idempotency
Send endpoints accept an Idempotency-Key header. A repeat of the same key with the same body replays the stored response instead of sending again. Keys are scoped to your workspace, fingerprinted against the request body and retained for 24 hours.
Why it matters
Without an idempotency key, a request that times out and is retried sends twice and is charged twice. The client never learns whether the first attempt succeeded, because the timeout tells you nothing about what the server did.
This is not a rare edge case. Mobile networks drop connections, load balancers time out, and every sensible HTTP client retries. On a payment OTP that means the customer gets two codes and you pay for both; on a campaign it can mean a duplicate blast to the whole list.
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-otp" \
-d '{
"sender": "Sendozi",
"recipient": "+2348012345678",
"message": "Your code is 492811. Valid for 10 minutes.",
"sms_type": "transactional"
}'What happens on a repeat
| Situation | Result |
|---|---|
| First use of the key | Processed normally. The response is stored. |
| Same key, same body | The original response is replayed. Nothing is sent and nothing is charged again. |
| Same key, different body | 409 idempotency_conflict. |
| Same key while the first is still running | 409 idempotency_conflict. |
| Key used on a request that was rejected | The key is released, so you can fix the problem and retry with the same key. |
- Keys are scoped to your workspace, so two customers cannot collide.
- Keys are retained for 24 hours. After that the same key is a fresh request.
- A rejected request releases its key, which is what makes 'fix the input and retry with the same key' safe.
Choosing a key
Derive the key from your own identifier for the thing being sent - an order id, a verification attempt id, a campaign id. A random value generated per retry defeats the entire mechanism, because each 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 |
| verify-8f21c4-attempt-1 | verify-8f21c4 | Two legitimate OTPs for one user need distinct keys |
| campaign-2026-08-21-batch-3 | campaign | Reused across days, so the second day replays the first |
| invoice-9921-reminder-2 | Date.now() | Changes on every call, so it is not a key at all |
async function sendWithRetry(payload: SendPayload, idempotencyKey: string, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch("https://api.sendozi.com/v1/sms/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDOZI_API_KEY}`,
"Content-Type": "application/json",
// The SAME key on every attempt. That is the whole point.
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
const body = await response.json();
if (body.success) return body.data;
// Permanent failures will fail identically next time.
if (response.status < 500 && body.error?.code !== "rate_limit_exceeded") {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
} catch (error) {
if (attempt === attempts) throw error;
}
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250));
}
}
// Key derived from the business event, stable across retries.
await sendWithRetry(payload, `order-${order.id}-shipped`);What not to retry
Idempotency makes a retry safe; it does not make one useful. These errors will return the same result however many times you try, and retrying them wastes your rate limit:
- invalid_request, invalid_recipient - fix the payload.
- sender_id_not_approved, kyc_required, channel_not_active - a gate, not a glitch.
- insufficient_balance - fund the wallet first.
- recipient_suppressed - the recipient opted out.
- idempotency_conflict - you reused a key with different content.
Retry 5xx responses, network timeouts, and rate_limit_exceeded after the delay named in its resolution. Back off exponentially, and cap the attempts.
Frequently asked questions
- How long does Sendozi remember an idempotency key?
- 24 hours, scoped to your workspace. After that the same key is treated as a new request.
- What happens if I reuse a key with a different message?
- The request is rejected with 409 idempotency_conflict. The body is fingerprinted against the stored key, so a changed payload under the same key is treated as a mistake rather than an overwrite.
- Should the idempotency key be random?
- No. A random key per retry defeats idempotency, because each attempt looks new. Derive it from your own identifier for the event - an order id or a verification attempt id.
- Does a failed request use up the key?
- No. A key on a rejected request is released, so you can correct the input and retry with the same key.
Related reading
Getting started
Quickstart: send your first SMS
Get an API key, send a sandbox SMS, read the response envelope, register a delivery webhook and move to production. A complete first integration in one page.
Channels
SMS API reference
Endpoints, request fields, routing, batching, sender ID management and page-based cost for sending SMS to Nigerian numbers through the Sendozi API.
Reliability
Errors and the response envelope
The Sendozi response envelope, every error code with its status and meaning, which errors are worth retrying, and how request_id is used to trace a failure.
Reliability
Rate limits and pagination
The Sendozi send rate limit, why it fails closed, how to back off correctly, and how keyset cursor pagination works on list endpoints.