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

  1. First call lands, runs, stores { key → response, body_hash } for 24h.
  2. Second call with same key + same body → returns cached response. No second send.
  3. Second call with same key + different body → 409 IDEMPOTENCY_MISMATCH.
  4. 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/emails per-message if you need it.