Idempotent send from a queue worker
When your queue delivers a job at-least-once (SQS, BullMQ, Upstash QStash), you'll occasionally replay a send. Idempotency keys make this safe — the replay returns the original response instead of double-sending.
Pattern
Derive the idempotency key from the job, not from the retry attempt. The key is the thing that stays stable across retries.
async function sendWelcomeEmail(job: { userId: string; jobId: string }) {
// Stable across retries of this same job:
const idempotencyKey = `welcome:${job.userId}:${job.jobId}`;
const res = await fetch("https://api.sendoka.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDOKA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
from: "welcome@yourdomain.com",
to: [await emailFor(job.userId)],
template: "welcome",
variables: { name: await nameFor(job.userId) },
}),
});
if (res.ok) return (await res.json()).id;
const { error } = await res.json();
if (error?.code === "IDEMPOTENCY_IN_FLIGHT") {
throw new RetryableError(error.message); // queue retries after delay
}
if (error?.code === "IDEMPOTENCY_MISMATCH") {
// Same key, different body → we changed the job payload mid-flight.
// Either regenerate a key or fix the upstream.
throw new FatalError(error.message);
}
throw new Error(`${res.status}: ${error?.message ?? "unknown"}`);
}
What Sendoka does on replay
- First call lands, runs, stores
{ key → response, body_hash }for 24h. - Second call with same key + same body → returns cached response. No second send.
- Second call with same key + different body →
409 IDEMPOTENCY_MISMATCH. - Second call during the first (30s window) →
409 IDEMPOTENCY_IN_FLIGHT. Your worker should back off ~500ms and retry.
Gotchas
- Don't include the retry attempt number in the key. Every retry would be a different key, defeating the point.
- Don't use a timestamp. Same.
- Do include enough entropy for uniqueness.
welcome:${userId}alone is ambiguous if a user signs up → deactivates → signs up again.${jobId}disambiguates. - The 24h window starts at first send. After that the key is forgotten and replays will double-send.
- Batch endpoints don't support idempotency keys. Use
/api/v1/emailsper-message if you need it.