Idempotency Keys

A way to retry a send without sending the email twice. You attach a key to the request; if sevk has seen that key before with the same payload, it returns the original response instead of running a second send.

Why this exists

Anything that calls POST /emails at-least-once is at risk of double-sending. The classic shape is a worker that calls sevk, gets a 200, then crashes before it writes anything down. The queue retries the job. The retry has no memory of the first call. The customer gets two payment receipts.

// without a key: your handler runs the send,
// then crashes before it can persist the result.
// the queue retries. the API has no memory of the
// first call. you double-send.
await sevk.emails.send({ from, to, subject, html })
await db.invoices.markEmailSent(invoice.id) // crash here

Idempotency keys move the deduplication into sevk. You pick a stable string that identifies the underlying intent (an invoice id, a webhook event id, a daily digest run id). sevk remembers the response for 24 hours and replays it on any retry that uses the same key with the same body.

const key = `invoice-paid/${invoice.id}`
await sevk.emails.send(
{ from, to, subject, html },
{ idempotencyKey: key }
)
await db.invoices.markEmailSent(invoice.id)
// crash here? next retry sends with the same key
// and gets the original response back, no second email.

What happens on retry

Three things can happen when a request lands on sevk with an Idempotency-Key set.

  • sevk has never seen this key. The send runs normally. sevk caches the response under (projectId, key) for 24 hours and returns it.
  • sevk has seen this key, the body matches. sevk returns the cached response without sending. The reply carries an Idempotent-Replayed: true response header so you can tell from the wire that it was a replay.
  • sevk has seen this key, the body is different. sevk returns 409. Either the key was reused for a different intent (a bug on your side), or you changed the payload between attempts (also a bug).

A fourth case: two retries arrive at the same time, before the first finishes. The second one returns 409. Once the first finishes and stores its response, the next retry replays.

Sending the key

HTTP

curl -X POST https://api.sevk.io/emails \
-H "Authorization: Bearer sevk_xxxxxxxxx" \
-H "Idempotency-Key: invoice-paid/inv_018f2b3c" \
-H "Content-Type: application/json" \
-d '{
"from": "Example <[email protected]>",
"to": ["[email protected]"],
"subject": "Payment received",
"html": "<p>Thanks!</p>"
}'

SDKs

Every SDK exposes idempotency on send and sendBulk. The argument shape varies by language convention:

NameTypeDescription
Node / Java / .NETidempotencyKeysend(data, { idempotencyKey })
Python / PHPidempotency_keysend(data, { idempotency_key })
Rubyidempotency_keysend(data, options: { idempotency_key: "..." })
GoSendOptionsSendWithOptions(params, &SendOptions{IdempotencyKey: "..."}) (same for SendBulkWithOptions)
RustSendOptionssend_with_options(params, &SendOptions { idempotency_key: Some("...".into()) })
CLI--idempotency-keysevk emails send --idempotency-key invoice-paid/inv_018f2b3c ...

SMTP

Add X-Idempotency-Key to the message you submit. The SMTP proxy lifts it onto the upstream HTTP request as Idempotency-Key. X-Sevk-Idempotency-Key is also accepted as an alias.

From: Example <[email protected]>
Subject: Payment received
X-Idempotency-Key: invoice-paid/inv_018f2b3c
<p>Thanks!</p>

The rules

  • Length and charset. 1 to 255 characters, ASCII printable. UUIDs, slugs, and <context>/<id> formats all qualify.
  • TTL. 24 hours from the stored response. After that the key is forgotten and the next request with that key sends as a new intent.
  • Project-scoped. Two API keys belonging to two different projects can use the same idempotency key string without collision. Within a project, the same string deduplicates.
  • The durable reservation is the boundary. Failures before sevk reserves quota and balance release the key so the same intent can retry. Once that reservation exists, sevk stores and replays the terminal response even if no email was queued, preventing an uncertain or partial request from running twice.
  • The whole body is hashed. For bulk sends that means changing one entry counts as a different payload. The key is for retries of the exact same batch, not for incremental sends.

Choosing a key

The key has to be derivable from the underlying event so that the retry produces the same string as the first attempt. A UUID generated at retry time defeats the point.

Good

  • invoice-paid/${invoice.id}: one event per invoice, retries always land on the same key.
  • stripe-webhook/${stripeEventId}: Stripe gives you the id, both deliveries of a redelivered webhook produce the same key.
  • weekly-digest/${weekISO}/${userId}: predictable per (week, user); a misfired cron retry is harmless.

Bad

  • crypto.randomUUID() generated at the top of your handler: every retry gets a different key, no dedup happens.
  • Date.now() or anything wall-clock based: the retry runs at a different time.
  • The user's email address: if the user can trigger this twice on purpose (e.g. clicks "resend code" twice), the second click is wrongly suppressed.

For bulk, pick something that names the batch (a job run id, a workspace+date pair), not any single recipient. The hash covers the entire { emails: [...] } array, so the key is paired with the exact batch contents on the first request.

// pick a key that names the batch, not any one row.
await sevk.emails.sendBulk(
{ emails: digestRecipients },
{ idempotencyKey: `weekly-digest/${runId}` }
)

Errors

NameTypeDescription
400 Bad Requestinvalid keyThe Idempotency-Key header is longer than 255 characters or contains non-printable characters. An empty header is silently ignored, as if it were omitted.
409 Conflictpayload mismatchThe key was already used with a different request body. Either you reused a key by mistake, or your retry mutated the payload. Use a fresh key or restore the original payload.
409 Conflictrequest in flightAnother request with the same key is still running. Retry once it settles; the next attempt will replay or 409 depending on whether the bodies match.