Skip to content
Sendozi

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.

By SendoziUpdated 3 min read

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

One header on the send request
cURL
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/send and POST /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

SituationResult
First use of the keyProcessed normally, and the response stored
Same key, identical body, same endpointThe 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 endpoint409 idempotency_conflict
Same key while the first request is still running409 idempotency_conflict; wait and retry
Same key after the first request was refusedProcessed as new: a refused request releases its key
Same key more than 24 hours after first useProcessed as new
  • Keys are per workspace. Two workspaces using the same key never collide.
  • The replayed response has a new top-level request_id; the data is 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.

GoodBadWhy
order-48291-shippedcrypto.randomUUID() per attemptA new key per attempt means every retry sends again
login-8f21c4-attempt-2login-8f21c4Two codes for the same login are two events and need two keys
campaign-2026-09-24-part-3campaignReused across sends, so the next send gets the old answer or a 409

A retry loop

Same key and same bytes on every attempt
Node.js
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.

Know which errors to retry

The error reference says which codes are worth another attempt.