Skip to content

feat(resend): Idempotency-Key header support#54

Open
yonatangross wants to merge 2 commits intovercel-labs:mainfrom
yonatangross:feat/resend-idempotency-key
Open

feat(resend): Idempotency-Key header support#54
yonatangross wants to merge 2 commits intovercel-labs:mainfrom
yonatangross:feat/resend-idempotency-key

Conversation

@yonatangross
Copy link
Copy Markdown

Summary

  • Add Idempotency-Key header support to POST /emails and POST /emails/batch
  • Same key + same payload returns the cached response (200), matching real Resend API behavior
  • Same key + different payload returns 409 invalid_idempotent_request, matching real Resend error handling
  • Key is stored on the entity but not exposed in GET /emails or GET /emails/:id responses

Changes

File Change
entities.ts Add idempotency_key: string | null to ResendEmail
store.ts Add idempotency_key to collection index for O(1) lookup
routes/emails.ts Idempotency logic for both /emails and /emails/batch
resend.test.ts 8 test cases: dedup, 409, batch, no-leak, no-webhook-on-dedup

How it works

Single email (POST /emails): Extract Idempotency-Key header → findOneBy indexed lookup → compare from/to/subject → return cached or 409.

Batch (POST /emails/batch): Same pattern but uses a deterministic fingerprint stored via store.setData() for payload comparison across the batch.

TTL: Real Resend expires keys after 24h. The emulator keeps them for the session lifetime (documented in code comments as intentional divergence — emulator state is ephemeral anyway).

Test plan

  • Same Idempotency-Key + same payload → returns same id (dedup)
  • Different Idempotency-Keys → separate emails created
  • Same key + different payload → 409 invalid_idempotent_request
  • No key → normal behavior, no dedup
  • Batch dedup with same key
  • Batch 409 on payload mismatch
  • No webhooks dispatched on deduplicated request
  • idempotency_key not exposed in GET responses
  • to: "x" normalizes same as to: ["x"] for comparison
  • Full monorepo test suite passes (390 tests, 0 failures)
  • Lint + type-check clean

🤖 Generated with Claude Code

Implement idempotency key handling for POST /emails and POST /emails/batch,
matching real Resend API behavior:

- Same key + same payload returns cached response (200)
- Same key + different payload returns 409 invalid_idempotent_request
- No key sends normally without dedup
- Key not exposed in GET /emails or GET /emails/:id responses
- Batch fingerprints stored via store.setData() for payload comparison
- Note: real Resend expires keys after 24h; emulator keeps them for session

4 files changed, +208 lines:
  entities.ts    — add idempotency_key: string | null field
  store.ts       — add idempotency_key to collection index for O(1) lookup
  emails.ts      — idempotency logic for both /emails and /emails/batch
  resend.test.ts — 8 test cases covering dedup, 409, batch, no-leak

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 8, 2026

@yonatangross is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c03d24fabb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The idempotency check for both /emails and /emails/batch only compared
from, to, and subject fields. A retry reusing the same Idempotency-Key
with different html, text, cc, bcc, reply_to, headers, tags, or
scheduled_at would silently return the cached response instead of 409.

Now uses a deterministic fingerprint of all request fields stored via
the data store, matching the real Resend API behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant