Why it matters
Retries are inevitable in distributed systems: client timeouts, transient 502s, deploys, provider hiccups, and flaky networks. Without idempotency, a retried create-style request can produce duplicate side-effects.
For an email API, that usually means duplicate sends, which is one of the fastest ways to lose user trust.
Idempotency lets you safely retry by telling SendLib: "this is the same logical operation as before".
How it works
For create-style endpoints, include a stable Idempotency-Key header. SendLib uses it to deduplicate requests over a retention window.
curl -X POST https://api.sendlib.com/v1/transmissions \
-H "Authorization: Bearer $SENDLIB_API_KEY" \
-H "Idempotency-Key: order-1042:receipt:v1" \
-H "Content-Type: application/json" \
-d '{"recipients":[{"email":"user@example.com"}],"content":{"subject":"Receipt","text":"Thanks!"}}'Don't generate a new key on retry
If you generate a new key each attempt (e.g. a new UUID per retry), you lose the protection. The key must be stable across every attempt of the same logical operation.
What happens on retry?
When you retry with the same Idempotency-Key:
- If the request is identical, SendLib returns the stored response (no duplicate side-effect).
- If the request body differs, SendLib responds with a conflict (typically
409) because a key can only represent one logical operation.
Choosing good keys
Good keys are deterministic and map 1:1 to a business operation.
| Key pattern | Example | Best for |
|---|---|---|
| Business object ID | order-1042:receipt | One send per order / invoice |
| Composite key | user-789:welcome:2026-02-06 | Periodic sends with a time bucket |
| Persisted UUID | 550e8400-e29b-41d4-a716-446655440000 | When you can't derive a natural key |
Persist the key before calling the API
Generate the idempotency key, store it in your DB, then call SendLib. On retry, load the same key from your DB instead of regenerating it.
Recommended retry policy
Idempotency is necessary but not sufficient. A good client retry policy is:
- Set timeouts for every request.
- Retry only on transient failures (
429,5xx, network errors). - Use exponential backoff with jitter.
- Cap retries and surface an error when you exceed the limit.
Implementation examples
// Node.js / TypeScript
export async function sendWithRetry(payload: unknown, idempotencyKey: string) {
const maxRetries = 3;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch("https://api.sendlib.com/v1/transmissions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDLIB_API_KEY}`,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.ok) return await res.json();
// Non-retryable client errors
if (res.status >= 400 && res.status < 500 && res.status !== 429 && res.status !== 409) {
throw new Error(`SendLib client error ${res.status}: ${await res.text()}`);
}
// Conflict means the key was reused with different inputs.
if (res.status === 409) {
throw new Error("Idempotency conflict (409). Reused key with different request body.");
}
const delayMs = Math.min(1000 * 2 ** attempt, 10_000) + Math.floor(Math.random() * 500);
await new Promise((r) => setTimeout(r, delayMs));
}
throw new Error("SendLib request failed after retries");
}Next
- Retry behavior: Rate Limits
- Debug failures: Errors
- Production patterns: Retries & outcomes