Back to skills
SkillHub ClubShip Full StackFull StackBackend

send-email

Use when sending transactional emails (welcome messages, order confirmations, password resets, receipts), notifications, or bulk emails via Resend API.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
89
Hot score
94
Updated
March 20, 2026
Overall rating
C3.6
Composite score
3.6
Best-practice grade
B75.6

Install command

npx @skill-hub/cli install resend-resend-skills-send-email

Repository

resend/resend-skills

Skill path: send-email

Use when sending transactional emails (welcome messages, order confirmations, password resets, receipts), notifications, or bulk emails via Resend API.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: resend.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install send-email into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/resend/resend-skills before adding send-email to shared team environments
  • Use send-email for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: send-email
description: Use when sending transactional emails (welcome messages, order confirmations, password resets, receipts), notifications, or bulk emails via Resend API.
---

# Send Email with Resend

## Overview

Resend provides two endpoints for sending emails:

| Approach | Endpoint | Use Case |
|----------|----------|----------|
| **Single** | `POST /emails` | Individual transactional emails, emails with attachments, scheduled sends |
| **Batch** | `POST /emails/batch` | Multiple distinct emails in one request (max 100), bulk notifications |

**Choose batch when:**
- Sending 2+ distinct emails at once
- Reducing API calls is important (by default, rate limit is 2 requests per second)
- No attachments or scheduling needed

**Choose single when:**
- Sending one email
- Email needs attachments
- Email needs to be scheduled
- Different recipients need different timing

## Quick Start

1. **Detect project language** from config files (package.json, requirements.txt, go.mod, etc.)
2. **Install SDK** (preferred) or use cURL - See [references/installation.md](references/installation.md)
3. **Choose single or batch** based on the decision matrix above
4. **Implement best practices** - Idempotency keys, error handling, retries

## Best Practices (Critical for Production)

Always implement these for production email sending. See [references/best-practices.md](references/best-practices.md) for complete implementations.

### Idempotency Keys

Prevent duplicate emails when retrying failed requests.

| Key Facts | |
|-----------|---|
| **Format (single)** | `<event-type>/<entity-id>` (e.g., `welcome-email/user-123`) |
| **Format (batch)** | `batch-<event-type>/<batch-id>` (e.g., `batch-orders/batch-456`) |
| **Expiration** | 24 hours |
| **Max length** | 256 characters |
| **Duplicate payload** | Returns original response without resending |
| **Different payload** | Returns 409 error |

### Error Handling

| Code | Action |
|------|--------|
| 400, 422 | Fix request parameters, don't retry |
| 401, 403 | Check API key / verify domain, don't retry |
| 409 | Idempotency conflict - use new key or fix payload |
| 429 | Rate limited - retry with exponential backoff (by default, rate limit is 2 requests/second) |
| 500 | Server error - retry with exponential backoff |

### Retry Strategy

- **Backoff:** Exponential (1s, 2s, 4s...)
- **Max retries:** 3-5 for most use cases
- **Only retry:** 429 (rate limit) and 500 (server error)
- **Always use:** Idempotency keys when retrying

## Single Email

**Endpoint:** `POST /emails` (prefer SDK over cURL)

### Required Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `from` | string | Sender address. Format: `"Name <[email protected]>"` |
| `to` | string[] | Recipient addresses (max 50) |
| `subject` | string | Email subject line |
| `html` or `text` | string | Email body content |

### Optional Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `cc` | string[] | CC recipients |
| `bcc` | string[] | BCC recipients |
| `reply_to`* | string[] | Reply-to addresses |
| `scheduled_at`* | string | Schedule send time (ISO 8601) |
| `attachments` | array | File attachments (max 40MB total) |
| `tags` | array | Key/value pairs for tracking (see [Tags](#tags)) |
| `headers` | object | Custom headers |

*Parameter naming varies by SDK (e.g., `replyTo` in Node.js, `reply_to` in Python).

### Minimal Example (Node.js)

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.emails.send(
  {
    from: 'Acme <[email protected]>',
    to: ['[email protected]'],
    subject: 'Hello World',
    html: '<p>Email body here</p>',
  },
  { idempotencyKey: `welcome-email/${userId}` }
);

if (error) {
  console.error('Failed:', error.message);
  return;
}
console.log('Sent:', data.id);
```

See [references/single-email-examples.md](references/single-email-examples.md) for all SDK implementations with error handling and retry logic.

## Batch Email

**Endpoint:** `POST /emails/batch` (but prefer SDK over cURL)

### Limitations

- **No attachments** - Use single sends for emails with attachments
- **No scheduling** - Use single sends for scheduled emails
- **Atomic** - If one email fails validation, the entire batch fails
- **Max 100 emails** per request
- **Max 50 recipients** per individual email in the batch

### Pre-validation

Since the entire batch fails on any validation error, validate all emails before sending:
- Check required fields (from, to, subject, html/text)
- Validate email formats
- Ensure batch size <= 100

### Minimal Example (Node.js)

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.batch.send(
  [
    {
      from: 'Acme <[email protected]>',
      to: ['[email protected]'],
      subject: 'Order Shipped',
      html: '<p>Your order has shipped!</p>',
    },
    {
      from: 'Acme <[email protected]>',
      to: ['[email protected]'],
      subject: 'Order Confirmed',
      html: '<p>Your order is confirmed!</p>',
    },
  ],
  { idempotencyKey: `batch-orders/${batchId}` }
);

if (error) {
  console.error('Batch failed:', error.message);
  return;
}
console.log('Sent:', data.map(e => e.id));
```

See [references/batch-email-examples.md](references/batch-email-examples.md) for all SDK implementations with validation, error handling, and retry logic.

## Large Batches (100+ Emails)

For sends larger than 100 emails, chunk into multiple batch requests:

1. **Split into chunks** of 100 emails each
2. **Use unique idempotency keys** per chunk: `<batch-prefix>/chunk-<index>`
3. **Send chunks in parallel** for better throughput
4. **Track results** per chunk to handle partial failures

See [references/batch-email-examples.md](references/batch-email-examples.md) for complete chunking implementations.

## Deliverability

Follow these practices to maximize inbox placement.

For more help with deliverability, install the email-best-practices skill with `npx skills add resend/email-best-practices`.

### Required

| Practice | Why |
|----------|-----|
| **Valid SPF, DKIM, DMARC record** | authenticate the email and prevent spoofing |
| **Links match sending domain** | If sending from `@acme.com`, link to `https://acme.com` - mismatched domains trigger spam filters |
| **Include plain text version** | Use both `html` and `text` parameters for accessibility and deliverability (Resend generates a plain text version if not provided) |
| **Avoid "no-reply" addresses** | Use real addresses (e.g., `support@`) - improves trust signals |
| **Keep body under 102KB** | Gmail clips larger messages |

### Recommended

| Practice | Why |
|----------|-----|
| **Use subdomains** | Send transactional from `notifications.acme.com`, marketing from `mail.acme.com` - protects reputation |
| **Disable tracking for transactional** | Open/click tracking can trigger spam filters for password resets, receipts, etc. |

## Tracking (Opens & Clicks)

Tracking is configured at the **domain level** in the Resend dashboard, not per-email.

| Setting | How it works | Recommendation |
|---------|--------------|----------------|
| **Open tracking** | Inserts 1x1 transparent pixel | Disable for transactional emails - can hurt deliverability |
| **Click tracking** | Rewrites links through redirect | Disable for sensitive emails (password resets, security alerts) |

**When to enable tracking:**
- Marketing emails where engagement metrics matter
- Newsletters and announcements

**When to disable tracking:**
- Transactional emails (receipts, confirmations, password resets)
- Security-sensitive emails
- When maximizing deliverability is priority

Configure via dashboard: Domain → Configuration → Click/Open Tracking

## Webhooks (Event Notifications)

Track email delivery status in real-time using webhooks. Resend sends HTTP POST requests to your endpoint when events occur.

| Event | When to use |
|-------|-------------|
| `email.delivered` | Confirm successful delivery |
| `email.bounced` | Remove from mailing list, alert user |
| `email.complained` | Unsubscribe user (spam complaint) |
| `email.opened` / `email.clicked` | Track engagement (marketing only) |

**CRITICAL: Always verify webhook signatures.** Without verification, attackers can send fake events to your endpoint.

See [references/webhooks.md](references/webhooks.md) for setup, signature verification code, and all event types.

## Tags

Tags are key/value pairs that help you track and filter emails.

```typescript
tags: [
  { name: 'user_id', value: 'usr_123' },
  { name: 'email_type', value: 'welcome' },
  { name: 'plan', value: 'enterprise' }
]
```

**Use cases:**
- Associate emails with customers in your system
- Categorize by email type (welcome, receipt, password-reset)
- Filter emails in the Resend dashboard
- Correlate webhook events back to your application

**Constraints:** Tag names and values can only contain ASCII letters, numbers, underscores, or dashes. Max 256 characters each.

## Templates

Use pre-built templates instead of sending HTML with each request.

```typescript
const { data, error } = await resend.emails.send({
  from: 'Acme <[email protected]>',
  to: ['[email protected]'],
  subject: 'Welcome!',
  template: {
    id: 'tmpl_abc123',
    variables: {
      USER_NAME: 'John',      // Case-sensitive!
      ORDER_TOTAL: '$99.00'
    }
  }
});
```

**IMPORTANT:** Variable names are **case-sensitive** and must match exactly as defined in the template editor. `USER_NAME` ≠ `user_name`.

| Fact | Detail |
|------|--------|
| **Max variables** | 20 per template |
| **Reserved names** | `FIRST_NAME`, `LAST_NAME`, `EMAIL`, `RESEND_UNSUBSCRIBE_URL`, `contact`, `this` |
| **Fallback values** | Optional - if not set and variable missing, send fails |
| **Can't combine with** | `html`, `text`, or `react` parameters |

Templates must be **published** in the dashboard before use. Draft templates won't work.

## Testing

**WARNING: Never test with fake addresses at real email providers.**

Using addresses like `[email protected]`, `[email protected]`, or `[email protected]` will:
- **Bounce** - These addresses don't exist
- **Destroy your sender reputation** - High bounce rates trigger spam filters
- **Get your domain blocklisted** - Providers flag domains with high bounce rates

### Safe Testing Options

| Method | Address | Result |
|--------|---------|--------|
| **Delivered** | `[email protected]` | Simulates successful delivery |
| **Bounced** | `[email protected]` | Simulates hard bounce |
| **Complained** | `[email protected]` | Simulates spam complaint |
| **Your own email** | Your actual address | Real delivery test |

**For development:** Use the `resend.dev` test addresses to simulate different scenarios without affecting your reputation.

**For staging:** Send to real addresses you control (team members, test accounts you own).

## Domain Warm-up

New domains must gradually increase sending volume to establish reputation.

**Why it matters:** Sudden high volume from a new domain triggers spam filters. ISPs expect gradual growth.

### Recommended Schedule

**Existing domain**

| Day | Messages per day    | Messages per hour   |
|-----|---------------------|---------------------|
| 1   | Up to 1,000 emails  | 100 Maximum         |
| 2   | Up to 2,500 emails  | 300 Maximum         |
| 3   | Up to 5,000 emails  | 600 Maximum         |
| 4   | Up to 5,000 emails  | 800 Maximum         |
| 5   | Up to 7,500 emails  | 1,000 Maximum       |
| 6   | Up to 7,500 emails  | 1,500 Maximum       |
| 7   | Up to 10,000 emails | 2,000 Maximum       |

**New domain**

| Day | Messages per day    | Messages per hour   |
|-----|---------------------|---------------------|
| 1   | Up to 150 emails    |                    |
| 2   | Up to 250 emails    |                    |
| 3   | Up to 400 emails    |                    |
| 4   | Up to 700 emails    | 50 Maximum         |
| 5   | Up to 1,000 emails  | 75 Maximum         |
| 6   | Up to 1,500 emails  | 100 Maximum        |
| 7   | Up to 2,000 emails  | 150 Maximum        |

### Monitor These Metrics

| Metric | Target | Action if exceeded |
|--------|--------|-------------------|
| **Bounce rate** | < 4% | Slow down, clean list |
| **Spam complaint rate** | < 0.08% | Slow down, review content |

**Don't use third-party warm-up services.** Focus on sending relevant content to real, engaged recipients.

## Suppression List

Resend automatically manages a suppression list of addresses that should not receive emails.

**Addresses are added when:**
- Email hard bounces (address doesn't exist)
- Recipient marks email as spam
- You manually add them via dashboard

**What happens:** Resend won't attempt delivery to suppressed addresses. The `email.suppressed` webhook event fires instead.

**Why this matters:** Continuing to send to bounced/complained addresses destroys your reputation. The suppression list protects you automatically.

**Management:** View and manage suppressed addresses in the Resend dashboard under Suppressions.

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Retrying without idempotency key | Always include idempotency key - prevents duplicate sends on retry |
| Using batch for emails with attachments | Batch doesn't support attachments - use single sends instead |
| Not validating batch before send | Validate all emails first - one invalid email fails the entire batch |
| Retrying 400/422 errors | These are validation errors - fix the request, don't retry |
| Same idempotency key, different payload | Returns 409 error - use unique key per unique email content |
| Tracking enabled for transactional emails | Disable open/click tracking for password resets, receipts - hurts deliverability |
| Using "no-reply" sender address | Use real address like `support@` - improves trust signals with email providers |
| Not verifying webhook signatures | Always verify - attackers can send fake events to your endpoint |
| Testing with fake emails ([email protected]) | Use `[email protected]` - fake addresses bounce and hurt reputation |
| Template variable name mismatch | Variable names are case-sensitive - `USER_NAME` ≠ `user_name` |
| Sending high volume from new domain | Warm up gradually - sudden spikes trigger spam filters |

## Notes

- The `from` address must use a verified domain
- If the sending address cannot receive replies, set the `reply_to` parameter to a valid address.
- Store API key in `RESEND_API_KEY` environment variable
- Node.js SDK supports `react` parameter for React Email components
- Resend returns `error`, `data`, `headers` in the response.
- Data returns `{ id: "email-id" }` on success (single) or array of IDs (batch)
- For marketing campaigns to large lists, use Resend Broadcasts instead

---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/installation.md

```markdown
# Resend SDK Installation Guide

## Detecting Project Language

Check for these files to determine the project's language/framework:

| File | Language | SDK |
|------|----------|-----|
| `package.json` | Node.js/TypeScript | resend |
| `requirements.txt` or `pyproject.toml` | Python | resend |
| `go.mod` | Go | resend-go/v3 |
| `Gemfile` | Ruby | resend |
| `composer.json` | PHP | resend/resend-php |
| `Cargo.toml` | Rust | resend-rs |
| `pom.xml` or `build.gradle` | Java | resend-java |
| `*.csproj` or `*.sln` | .NET | Resend |
| `mix.exs` | Elixir | resend |

## Installation Commands

### Node.js

```bash
npm install resend
```

Alternative package managers:
```bash
yarn add resend
pnpm add resend
bun add resend
```

### Python

```bash
pip install resend
```

### Go

```bash
go get github.com/resend/resend-go/v3
```

### Ruby

```bash
gem install resend
```

Or add to Gemfile:
```ruby
gem 'resend'
```

### PHP

```bash
composer require resend/resend-php
```

### Rust

```bash
cargo add resend-rs
cargo add tokio -F macros,rt-multi-thread
```

### Java

Gradle:
```gradle
implementation 'com.resend:resend-java:+'
```

Maven:
```xml
<dependency>
  <groupId>com.resend</groupId>
  <artifactId>resend-java</artifactId>
  <version>LATEST</version>
</dependency>
```

### .NET

```bash
dotnet add package Resend
```

### Elixir

Add to `mix.exs`:
```elixir
def deps do
  [
    {:resend, "~> 0.4.0"}
  ]
end
```

## API Key Setup

All SDKs require a Resend API key. Get one at https://resend.com/api-keys

Recommended: Store API key in environment variable `RESEND_API_KEY` rather than hardcoding.

## cURL (No SDK)

For quick tests or languages without an SDK, use the REST API directly:

```bash
curl -X POST 'https://api.resend.com/emails' \
  -H 'Authorization: Bearer re_xxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "hello world",
    "html": "<p>it works!</p>"
  }'
```

```

### references/best-practices.md

```markdown
# Best Practices for Sending Emails with Resend

## Table of Contents

- [Idempotency Keys](#idempotency-keys)
- [Error Handling](#error-handling)
- [Retry Logic](#retry-logic)
- [Batch-Specific Practices](#batch-specific-practices)

## Idempotency Keys

Use idempotency keys to prevent duplicate emails when retrying failed requests.

### Key Facts

- **Expiration:** Keys expire after 24 hours
- **Max length:** 256 characters
- **Format:** Use `<event-type>/<entity-id>` pattern for single emails, `batch-<event-type>/<batch-id>` for batch
- **Behavior:** Same key + same payload = returns original response without resending
- **Conflict:** Same key + different payload = returns 409 error

### Examples by Format

| Use Case | Key Format | Example |
|----------|------------|---------|
| Welcome email | `welcome-email/<user-id>` | `welcome-email/user-123` |
| Order confirmation | `order-confirmation/<order-id>` | `order-confirmation/order-456` |
| Password reset | `password-reset/<user-id>/<timestamp>` | `password-reset/user-123/1705123456` |
| Batch notifications | `batch-<event>/<batch-id>` | `batch-order-notifications/batch-789` |
| Large batch chunk | `<batch-prefix>/chunk-<index>` | `campaign-abc/chunk-0` |

### Node.js

The Node.js SDK has a dedicated `idempotencyKey` option:

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

// Single email
const { data, error } = await resend.emails.send(
  {
    from: 'Acme <[email protected]>',
    to: ['[email protected]'],
    subject: 'Order Confirmation',
    html: '<p>Your order has been confirmed.</p>',
  },
  {
    idempotencyKey: `order-confirmation/${orderId}`,
  }
);

// Batch email
const { data, error } = await resend.batch.send(
  [
    { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
    { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
  ],
  { idempotencyKey: `batch-welcome/${batchId}` }
);
```

### Python

```python
import resend
import os

resend.api_key = os.environ["RESEND_API_KEY"]

# Single email
email = resend.Emails.send({
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Order Confirmation",
    "html": "<p>Your order has been confirmed.</p>",
}, idempotency_key=f"order-confirmation/{order_id}")

# Batch email
result = resend.Batch.send(emails, idempotency_key=f"batch-orders/{batch_id}")
```

### Go

Other SDKs use the `Idempotency-Key` header:

```go
import "github.com/resend/resend-go/v3"

client := resend.NewClient(os.Getenv("RESEND_API_KEY"))

// Single email
params := &resend.SendEmailRequest{
    From:    "Acme <[email protected]>",
    To:      []string{"[email protected]"},
    Subject: "Order Confirmation",
    Html:    "<p>Your order has been confirmed.</p>",
    Headers: map[string]string{
        "Idempotency-Key": fmt.Sprintf("order-confirmation/%s", orderID),
    },
}

sent, err := client.Emails.Send(params)
```

### cURL

```bash
curl -X POST 'https://api.resend.com/emails' \
  -H 'Authorization: Bearer re_xxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-confirmation/12345' \
  -d '{
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Order Confirmation",
    "html": "<p>Your order has been confirmed.</p>"
  }'
```

## Error Handling

### Common Error Codes

| Code | Name | Description | Action |
|------|------|-------------|--------|
| 400 | `validation_error` | Invalid parameters | Fix request, don't retry |
| 400 | `invalid_idempotency_key` | Key must be 1-256 characters | Fix key format, don't retry |
| 401 | `authentication_error` | Invalid API key | Check RESEND_API_KEY, don't retry |
| 403 | `authorization_error` | Domain not verified | Verify domain at resend.com/domains |
| 409 | `invalid_idempotent_request` | Key used with different payload | Use new key or fix payload |
| 409 | `concurrent_idempotent_requests` | Same key request in progress | Wait and retry |
| 422 | `unprocessable_entity` | Invalid email format/content | Fix content, don't retry |
| 429 | `rate_limit_exceeded` | Too many requests | Retry with exponential backoff |
| 500 | `api_error` | Server error | Retry with exponential backoff |

### Retryable vs Non-Retryable

**Don't retry (fix the request):**
- 400 - Bad request / validation errors
- 401 - Invalid API key
- 403 - Domain not verified
- 409 - Idempotency conflict (different payload)
- 422 - Unprocessable entity

**Safe to retry with backoff:**
- 429 - Rate limited
- 500 - Server error

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.emails.send({
  from: 'Acme <[email protected]>',
  to: ['[email protected]'],
  subject: 'Hello',
  html: '<p>Hello world</p>',
});

if (error) {
  switch (error.name) {
    case 'validation_error':
      // Invalid parameters - don't retry, fix the data
      throw new Error(`Invalid email params: ${error.message}`);

    case 'rate_limit_exceeded':
      // Rate limited - safe to retry with backoff
      console.log('Rate limited, should retry with backoff');
      break;

    case 'api_error':
      // Server error - safe to retry
      console.log('Server error, should retry');
      break;

    case 'invalid_idempotent_request':
      // Idempotency conflict - don't retry with same key
      throw new Error('Duplicate request with different payload');

    default:
      console.error('Unexpected error:', error);
  }
  return;
}

console.log('Email sent:', data.id);
```

### Python

```python
import resend
import os

resend.api_key = os.environ["RESEND_API_KEY"]

try:
    email = resend.Emails.send({
        "from": "Acme <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Hello",
        "html": "<p>Hello world</p>",
    })
    print(f"Email sent: {email['id']}")
except resend.exceptions.ValidationError as e:
    # Invalid parameters - don't retry
    print(f"Validation error: {e}")
except resend.exceptions.RateLimitError as e:
    # Rate limited - retry after delay
    print(f"Rate limited: {e}")
except resend.exceptions.ResendError as e:
    # Other API error
    print(f"API error: {e}")
```

### Go

```go
import (
    "fmt"
    "github.com/resend/resend-go/v3"
)

client := resend.NewClient(os.Getenv("RESEND_API_KEY"))

params := &resend.SendEmailRequest{
    From:    "Acme <[email protected]>",
    To:      []string{"[email protected]"},
    Subject: "Hello",
    Html:    "<p>Hello world</p>",
}

sent, err := client.Emails.Send(params)
if err != nil {
    // Check error type and handle accordingly
    fmt.Printf("Failed to send email: %v\n", err)
    return
}

fmt.Printf("Email sent: %s\n", sent.Id)
```

## Retry Logic

Implement exponential backoff for transient failures. Don't retry validation errors or idempotency conflicts.

### Strategy

| Attempt | Delay | Total Wait |
|---------|-------|------------|
| 1 | 1s | 1s |
| 2 | 2s | 3s |
| 3 | 4s | 7s |
| 4 | 8s | 15s |
| 5 | 16s | 31s |

**Recommendations:**
- Max 3-5 retries for most use cases
- Only retry 429 (rate limit) and 500 (server error)
- Always use idempotency keys when retrying

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function sendEmailWithRetry(
  params: Parameters<typeof resend.emails.send>[0],
  options: { maxRetries?: number; idempotencyKey?: string } = {}
) {
  const { maxRetries = 3, idempotencyKey } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const { data, error } = await resend.emails.send(
      params,
      idempotencyKey ? { idempotencyKey } : undefined
    );

    if (!error) {
      return data;
    }

    // Don't retry validation errors or idempotency conflicts
    if (error.name === 'validation_error' || error.name === 'invalid_idempotent_request') {
      throw new Error(`${error.name}: ${error.message}`);
    }

    // Last attempt failed
    if (attempt === maxRetries) {
      throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);
    }

    // Exponential backoff: 1s, 2s, 4s...
    const delay = Math.pow(2, attempt) * 1000;
    console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}

// Usage
const result = await sendEmailWithRetry(
  {
    from: 'Acme <[email protected]>',
    to: ['[email protected]'],
    subject: 'Order Confirmation',
    html: '<p>Your order is confirmed.</p>',
  },
  { idempotencyKey: `order-confirmation/${orderId}` }
);
```

### Python

```python
import resend
import os
import time

resend.api_key = os.environ["RESEND_API_KEY"]

def send_email_with_retry(params, max_retries=3, idempotency_key=None):
    for attempt in range(max_retries + 1):
        try:
            return resend.Emails.send(params, idempotency_key=idempotency_key)
        except resend.exceptions.ValidationError:
            # Don't retry validation errors
            raise
        except resend.exceptions.ResendError as e:
            if attempt == max_retries:
                raise Exception(f"Failed after {max_retries + 1} attempts: {e}")

            # Exponential backoff: 1s, 2s, 4s...
            delay = (2 ** attempt)
            print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
            time.sleep(delay)

# Usage
result = send_email_with_retry(
    {
        "from": "Acme <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Order Confirmation",
        "html": "<p>Your order is confirmed.</p>",
    },
    idempotency_key=f"order-confirmation/{order_id}"
)
```

### Go

```go
import (
    "fmt"
    "time"
    "github.com/resend/resend-go/v3"
)

func sendEmailWithRetry(client *resend.Client, params *resend.SendEmailRequest, maxRetries int) (*resend.SendEmailResponse, error) {
    var lastErr error

    for attempt := 0; attempt <= maxRetries; attempt++ {
        sent, err := client.Emails.Send(params)
        if err == nil {
            return sent, nil
        }

        lastErr = err

        if attempt == maxRetries {
            break
        }

        // Exponential backoff: 1s, 2s, 4s...
        delay := time.Duration(1<<attempt) * time.Second
        fmt.Printf("Attempt %d failed, retrying in %v...\n", attempt+1, delay)
        time.Sleep(delay)
    }

    return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries+1, lastErr)
}

// Usage
client := resend.NewClient(os.Getenv("RESEND_API_KEY"))

params := &resend.SendEmailRequest{
    From:    "Acme <[email protected]>",
    To:      []string{"[email protected]"},
    Subject: "Order Confirmation",
    Html:    "<p>Your order is confirmed.</p>",
    Headers: map[string]string{
        "Idempotency-Key": fmt.Sprintf("order-confirmation/%s", orderID),
    },
}

sent, err := sendEmailWithRetry(client, params, 3)
```

## Batch-Specific Practices

### Pre-send Validation

The entire batch fails if any single email has invalid data. Always validate before sending.

**Key validations:**
- Batch size: 1-100 emails
- Recipients per email: 1-50
- Required fields: `from`, `to`, `subject`, `html` or `text`
- Valid email format for all recipients

See [batch-email-examples.md](batch-email-examples.md) for complete validation implementations.

### Chunking Large Batches

For sends larger than 100 emails, chunk into multiple batch requests with unique idempotency keys per chunk.

```typescript
// Node.js example pattern
const BATCH_SIZE = 100;

async function sendLargeBatch(emails: Email[], batchPrefix: string) {
  const chunks: Email[][] = [];

  for (let i = 0; i < emails.length; i += BATCH_SIZE) {
    chunks.push(emails.slice(i, i + BATCH_SIZE));
  }

  const results = await Promise.all(
    chunks.map(async (chunk, index) => {
      // Each chunk gets its own idempotency key
      const idempotencyKey = `${batchPrefix}/chunk-${index}`;
      return resend.batch.send(chunk, { idempotencyKey });
    })
  );

  return results;
}
```

See [batch-email-examples.md](batch-email-examples.md) for complete chunking implementations in all SDKs.

### Batch Limitations

Remember that the batch endpoint does **NOT** support:
- `attachments` - Use individual sends for emails with attachments
- `scheduled_at` - Use individual sends for scheduled emails
- Partial success - If one email fails validation, the entire batch fails

```

### references/single-email-examples.md

```markdown
# Resend Examples

## Table of Contents

- [Idempotency Keys](#idempotency-keys)
- [Error Handling](#error-handling)
- [Retry Logic](#retry-logic)
- [Complete Examples](#complete-examples)

## Idempotency Keys

Use idempotency keys to prevent duplicate emails when retrying failed requests. Keys expire after 24 hours and have a max length of 256 characters.

**Key format:** Use `<event-type>/<entity-id>` pattern (e.g., `welcome-email/user-123`, `order-confirmation/order-456`).

**Behavior:** If same key is sent with a different payload, Resend returns a 409 error. If same key with same payload, returns the original response without resending.

### Node.js

The Node.js SDK has a dedicated `idempotencyKey` option:

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.emails.send(
  {
    from: 'Acme <[email protected]>',
    to: ['[email protected]'],
    subject: 'Order Confirmation',
    html: '<p>Your order has been confirmed.</p>',
  },
  {
    idempotencyKey: `order-confirmation/${orderId}`,
  }
);
```

### Python

```python
import resend
import os

resend.api_key = os.environ["RESEND_API_KEY"]

email = resend.Emails.send({
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Order Confirmation",
    "html": "<p>Your order has been confirmed.</p>",
}, idempotency_key=f"order-confirmation/{order_id}")
```

### Go

Other SDKs use the `Idempotency-Key` header:

```go
import "github.com/resend/resend-go/v3"

client := resend.NewClient(os.Getenv("RESEND_API_KEY"))

params := &resend.SendEmailRequest{
    From:    "Acme <[email protected]>",
    To:      []string{"[email protected]"},
    Subject: "Order Confirmation",
    Html:    "<p>Your order has been confirmed.</p>",
    Headers: map[string]string{
        "Idempotency-Key": fmt.Sprintf("order-confirmation/%s", orderID),
    },
}

sent, err := client.Emails.Send(params)
```

### cURL

```bash
curl -X POST 'https://api.resend.com/emails' \
  -H 'Authorization: Bearer re_xxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-confirmation/12345' \
  -d '{
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Order Confirmation",
    "html": "<p>Your order has been confirmed.</p>"
  }'
```

## Error Handling

### Common Error Codes

| Code | Description | Action |
|------|-------------|--------|
| 400 | Bad request (invalid params) | Fix request parameters |
| 400 | `invalid_idempotency_key` | Key must be 1-256 characters |
| 401 | Invalid API key | Check RESEND_API_KEY |
| 403 | Domain not verified | Verify domain at resend.com/domains |
| 409 | `invalid_idempotent_request` | Key already used with different payload |
| 409 | `concurrent_idempotent_requests` | Same key request in progress, retry later |
| 422 | Unprocessable entity | Check email format/content |
| 429 | Rate limited | Implement backoff and retry |
| 500 | Server error | Retry with backoff |

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.emails.send({
  from: 'Acme <[email protected]>',
  to: ['[email protected]'],
  subject: 'Hello',
  html: '<p>Hello world</p>',
});

if (error) {
  console.error('Failed to send email:', error.message);

  // Handle specific error types
  if (error.name === 'validation_error') {
    // Invalid parameters - don't retry
    throw new Error(`Invalid email params: ${error.message}`);
  }

  if (error.name === 'rate_limit_exceeded') {
    // Rate limited - retry after delay
    console.log('Rate limited, retrying...');
  }

  return;
}

console.log('Email sent:', data.id);
```

### Python

```python
import resend
import os

resend.api_key = os.environ["RESEND_API_KEY"]

try:
    email = resend.Emails.send({
        "from": "Acme <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Hello",
        "html": "<p>Hello world</p>",
    })
    print(f"Email sent: {email['id']}")
except resend.exceptions.ValidationError as e:
    # Invalid parameters - don't retry
    print(f"Validation error: {e}")
except resend.exceptions.RateLimitError as e:
    # Rate limited - retry after delay
    print(f"Rate limited: {e}")
except resend.exceptions.ResendError as e:
    # Other API error
    print(f"API error: {e}")
```

### Go

```go
import (
    "fmt"
    "github.com/resend/resend-go/v3"
)

client := resend.NewClient(os.Getenv("RESEND_API_KEY"))

params := &resend.SendEmailRequest{
    From:    "Acme <[email protected]>",
    To:      []string{"[email protected]"},
    Subject: "Hello",
    Html:    "<p>Hello world</p>",
}

sent, err := client.Emails.Send(params)
if err != nil {
    // Check error type and handle accordingly
    fmt.Printf("Failed to send email: %v\n", err)
    return
}

fmt.Printf("Email sent: %s\n", sent.Id)
```

## Retry Logic

Implement exponential backoff for transient failures (rate limits, server errors). Don't retry 409 `invalid_idempotent_request` errors (different payload).

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function sendEmailWithRetry(
  params: Parameters<typeof resend.emails.send>[0],
  options: { maxRetries?: number; idempotencyKey?: string } = {}
) {
  const { maxRetries = 3, idempotencyKey } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const { data, error } = await resend.emails.send(
      params,
      idempotencyKey ? { idempotencyKey } : undefined
    );

    if (!error) {
      return data;
    }

    // Don't retry validation errors or idempotency conflicts
    if (error.name === 'validation_error' || error.name === 'invalid_idempotent_request') {
      throw new Error(`${error.name}: ${error.message}`);
    }

    // Last attempt failed
    if (attempt === maxRetries) {
      throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);
    }

    // Exponential backoff: 1s, 2s, 4s...
    const delay = Math.pow(2, attempt) * 1000;
    console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}

// Usage
const result = await sendEmailWithRetry(
  {
    from: 'Acme <[email protected]>',
    to: ['[email protected]'],
    subject: 'Order Confirmation',
    html: '<p>Your order is confirmed.</p>',
  },
  { idempotencyKey: `order-confirmation/${orderId}` }
);
```

### Python

```python
import resend
import os
import time

resend.api_key = os.environ["RESEND_API_KEY"]

def send_email_with_retry(params, max_retries=3, idempotency_key=None):
    for attempt in range(max_retries + 1):
        try:
            return resend.Emails.send(params, idempotency_key=idempotency_key)
        except resend.exceptions.ValidationError:
            # Don't retry validation errors
            raise
        except resend.exceptions.ResendError as e:
            if attempt == max_retries:
                raise Exception(f"Failed after {max_retries + 1} attempts: {e}")

            # Exponential backoff: 1s, 2s, 4s...
            delay = (2 ** attempt)
            print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
            time.sleep(delay)

# Usage
result = send_email_with_retry(
    {
        "from": "Acme <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Order Confirmation",
        "html": "<p>Your order is confirmed.</p>",
    },
    idempotency_key=f"order-confirmation/{order_id}"
)
```

### Go

```go
import (
    "fmt"
    "time"
    "github.com/resend/resend-go/v3"
)

func sendEmailWithRetry(client *resend.Client, params *resend.SendEmailRequest, maxRetries int) (*resend.SendEmailResponse, error) {
    var lastErr error

    for attempt := 0; attempt <= maxRetries; attempt++ {
        sent, err := client.Emails.Send(params)
        if err == nil {
            return sent, nil
        }

        lastErr = err

        if attempt == maxRetries {
            break
        }

        // Exponential backoff: 1s, 2s, 4s...
        delay := time.Duration(1<<attempt) * time.Second
        fmt.Printf("Attempt %d failed, retrying in %v...\n", attempt+1, delay)
        time.Sleep(delay)
    }

    return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries+1, lastErr)
}

// Usage
client := resend.NewClient(os.Getenv("RESEND_API_KEY"))

params := &resend.SendEmailRequest{
    From:    "Acme <[email protected]>",
    To:      []string{"[email protected]"},
    Subject: "Order Confirmation",
    Html:    "<p>Your order is confirmed.</p>",
    Headers: map[string]string{
        "Idempotency-Key": fmt.Sprintf("order-confirmation/%s", orderID),
    },
}

sent, err := sendEmailWithRetry(client, params, 3)
```

## Complete Examples

### Node.js - Production-Ready Email Service

```typescript
import { Resend } from 'resend';
import { randomUUID } from 'crypto';

const resend = new Resend(process.env.RESEND_API_KEY);

interface SendEmailOptions {
  to: string | string[];
  subject: string;
  html: string;
  from?: string;
  replyTo?: string;
  idempotencyKey?: string;
  maxRetries?: number;
}

async function sendEmail(options: SendEmailOptions) {
  const {
    to,
    subject,
    html,
    from = 'Acme <[email protected]>',
    replyTo,
    idempotencyKey = randomUUID(),
    maxRetries = 3,
  } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const { data, error } = await resend.emails.send(
      {
        from,
        to: Array.isArray(to) ? to : [to],
        subject,
        html,
        ...(replyTo && { replyTo }),
      },
      { idempotencyKey }
    );

    if (!error) {
      return { success: true, id: data.id };
    }

    // Don't retry client errors or idempotency conflicts
    if (error.name === 'validation_error' || error.name === 'not_found' || error.name === 'invalid_idempotent_request') {
      return { success: false, error: error.message, retryable: false };
    }

    if (attempt < maxRetries) {
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(r => setTimeout(r, delay));
    }
  }

  return { success: false, error: 'Max retries exceeded', retryable: true };
}

// Usage
const result = await sendEmail({
  to: '[email protected]',
  subject: 'Welcome!',
  html: '<h1>Welcome to Acme</h1>',
  idempotencyKey: `welcome-email/${userId}`,
});
```

### Python - Production-Ready Email Service

```python
import resend
import os
import time
import uuid
from dataclasses import dataclass
from typing import Optional, Union, List

resend.api_key = os.environ["RESEND_API_KEY"]

@dataclass
class EmailResult:
    success: bool
    id: Optional[str] = None
    error: Optional[str] = None
    retryable: bool = False

def send_email(
    to: Union[str, List[str]],
    subject: str,
    html: str,
    from_addr: str = "Acme <[email protected]>",
    reply_to: Optional[str] = None,
    idempotency_key: Optional[str] = None,
    max_retries: int = 3,
) -> EmailResult:
    idempotency_key = idempotency_key or str(uuid.uuid4())
    recipients = [to] if isinstance(to, str) else to

    params = {
        "from": from_addr,
        "to": recipients,
        "subject": subject,
        "html": html,
    }
    if reply_to:
        params["reply_to"] = reply_to

    for attempt in range(max_retries + 1):
        try:
            result = resend.Emails.send(params, idempotency_key=idempotency_key)
            return EmailResult(success=True, id=result["id"])
        except resend.exceptions.ValidationError as e:
            return EmailResult(success=False, error=str(e), retryable=False)
        except resend.exceptions.ResendError as e:
            if attempt < max_retries:
                time.sleep(2 ** attempt)
            else:
                return EmailResult(success=False, error=str(e), retryable=True)

    return EmailResult(success=False, error="Max retries exceeded", retryable=True)

# Usage
result = send_email(
    to="[email protected]",
    subject="Welcome!",
    html="<h1>Welcome to Acme</h1>",
    idempotency_key=f"welcome-email/{user_id}",
)
```

```

### references/batch-email-examples.md

```markdown
# Batch Email Examples

## Table of Contents

- [Pre-send Validation](#pre-send-validation)
- [Error Handling](#error-handling)
- [Retry Logic with Idempotency](#retry-logic-with-idempotency)
- [Chunking Large Batches](#chunking-large-batches)
- [Production-Ready Implementations](#production-ready-implementations)

## Pre-send Validation

Since the entire batch fails if any email has invalid data, validate before sending.

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

interface BatchEmail {
  from: string;
  to: string[];
  subject: string;
  html?: string;
  text?: string;
}

interface ValidationResult {
  valid: boolean;
  errors: { index: number; field: string; message: string }[];
}

function validateBatch(emails: BatchEmail[]): ValidationResult {
  const errors: ValidationResult['errors'] = [];

  if (emails.length === 0) {
    return { valid: false, errors: [{ index: -1, field: 'batch', message: 'Batch cannot be empty' }] };
  }

  if (emails.length > 100) {
    return { valid: false, errors: [{ index: -1, field: 'batch', message: 'Batch cannot exceed 100 emails' }] };
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  emails.forEach((email, index) => {
    if (!email.from) {
      errors.push({ index, field: 'from', message: 'From address is required' });
    }

    if (!email.to || email.to.length === 0) {
      errors.push({ index, field: 'to', message: 'At least one recipient is required' });
    } else if (email.to.length > 50) {
      errors.push({ index, field: 'to', message: 'Cannot exceed 50 recipients per email' });
    } else {
      email.to.forEach((recipient, rIndex) => {
        if (!emailRegex.test(recipient)) {
          errors.push({ index, field: `to[${rIndex}]`, message: `Invalid email: ${recipient}` });
        }
      });
    }

    if (!email.subject) {
      errors.push({ index, field: 'subject', message: 'Subject is required' });
    }

    if (!email.html && !email.text) {
      errors.push({ index, field: 'content', message: 'Either html or text content is required' });
    }
  });

  return { valid: errors.length === 0, errors };
}

// Usage
const emails = [
  { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
];

const validation = validateBatch(emails);
if (!validation.valid) {
  console.error('Validation failed:', validation.errors);
  return;
}

const { data, error } = await resend.batch.send(emails);
```

### Python

```python
import resend
import re
import os
from dataclasses import dataclass
from typing import List, Optional

resend.api_key = os.environ["RESEND_API_KEY"]

@dataclass
class ValidationError:
    index: int
    field: str
    message: str

def validate_batch(emails: List[dict]) -> tuple[bool, List[ValidationError]]:
    errors = []
    email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')

    if not emails:
        return False, [ValidationError(-1, 'batch', 'Batch cannot be empty')]

    if len(emails) > 100:
        return False, [ValidationError(-1, 'batch', 'Batch cannot exceed 100 emails')]

    for index, email in enumerate(emails):
        if not email.get('from'):
            errors.append(ValidationError(index, 'from', 'From address is required'))

        to_list = email.get('to', [])
        if not to_list:
            errors.append(ValidationError(index, 'to', 'At least one recipient is required'))
        elif len(to_list) > 50:
            errors.append(ValidationError(index, 'to', 'Cannot exceed 50 recipients per email'))
        else:
            for r_index, recipient in enumerate(to_list):
                if not email_regex.match(recipient):
                    errors.append(ValidationError(index, f'to[{r_index}]', f'Invalid email: {recipient}'))

        if not email.get('subject'):
            errors.append(ValidationError(index, 'subject', 'Subject is required'))

        if not email.get('html') and not email.get('text'):
            errors.append(ValidationError(index, 'content', 'Either html or text content is required'))

    return len(errors) == 0, errors

# Usage
emails = [
    {"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
]

valid, errors = validate_batch(emails)
if not valid:
    print(f"Validation failed: {errors}")
else:
    result = resend.Batch.send(emails)
```

## Error Handling

### Common Error Codes

| Code | Description | Action |
|------|-------------|--------|
| 400 | Bad request (invalid params) | Fix request parameters, don't retry |
| 401 | Invalid API key | Check RESEND_API_KEY, don't retry |
| 403 | Domain not verified | Verify domain at resend.com/domains |
| 409 | Idempotency conflict | Same key with different payload |
| 422 | Unprocessable entity | Check email format/content, don't retry |
| 429 | Rate limited | Retry with exponential backoff |
| 500 | Server error | Retry with exponential backoff |

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.batch.send(emails);

if (error) {
  switch (error.name) {
    case 'validation_error':
      // Invalid parameters - don't retry, fix the data
      console.error('Validation error:', error.message);
      throw new Error(`Invalid batch data: ${error.message}`);

    case 'rate_limit_exceeded':
      // Rate limited - safe to retry with backoff
      console.log('Rate limited, should retry with backoff');
      break;

    case 'api_error':
      // Server error - safe to retry
      console.log('Server error, should retry');
      break;

    default:
      console.error('Unexpected error:', error);
  }
  return;
}

console.log('Batch sent successfully:', data);
```

### Python

```python
import resend
import os

resend.api_key = os.environ["RESEND_API_KEY"]

try:
    result = resend.Batch.send(emails)
    print(f"Batch sent: {result}")
except resend.exceptions.ValidationError as e:
    # Invalid parameters - don't retry
    print(f"Validation error (don't retry): {e}")
    raise
except resend.exceptions.RateLimitError as e:
    # Rate limited - retry with backoff
    print(f"Rate limited (retry with backoff): {e}")
except resend.exceptions.ResendError as e:
    # Other API error - may retry
    print(f"API error: {e}")
```

## Retry Logic with Idempotency

Combine retry logic with idempotency keys to safely retry failed batches.

### Node.js

```typescript
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

interface BatchSendOptions {
  maxRetries?: number;
  idempotencyKey: string;
}

async function sendBatchWithRetry(
  emails: Parameters<typeof resend.batch.send>[0],
  options: BatchSendOptions
) {
  const { maxRetries = 3, idempotencyKey } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const { data, error } = await resend.batch.send(emails, {
      idempotencyKey,
    });

    if (!error) {
      return { success: true, data };
    }

    // Don't retry validation errors
    if (error.name === 'validation_error') {
      return { success: false, error: error.message, retryable: false };
    }

    // Don't retry idempotency conflicts
    if (error.name === 'idempotency_error') {
      return { success: false, error: 'Duplicate request with different payload', retryable: false };
    }

    // Last attempt failed
    if (attempt === maxRetries) {
      return { success: false, error: error.message, retryable: true };
    }

    // Exponential backoff: 1s, 2s, 4s...
    const delay = Math.pow(2, attempt) * 1000;
    console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
    await new Promise(resolve => setTimeout(resolve, delay));
  }

  return { success: false, error: 'Max retries exceeded', retryable: true };
}

// Usage
const result = await sendBatchWithRetry(
  [
    { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
    { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
  ],
  { idempotencyKey: `batch-welcome/${batchId}` }
);

if (result.success) {
  console.log('Batch sent:', result.data);
} else {
  console.error('Batch failed:', result.error);
}
```

### Python

```python
import resend
import os
import time
from dataclasses import dataclass
from typing import Optional, List

resend.api_key = os.environ["RESEND_API_KEY"]

@dataclass
class BatchResult:
    success: bool
    data: Optional[List[dict]] = None
    error: Optional[str] = None
    retryable: bool = False

def send_batch_with_retry(
    emails: List[dict],
    idempotency_key: str,
    max_retries: int = 3
) -> BatchResult:
    for attempt in range(max_retries + 1):
        try:
            result = resend.Batch.send(emails, idempotency_key=idempotency_key)
            return BatchResult(success=True, data=result)
        except resend.exceptions.ValidationError as e:
            # Don't retry validation errors
            return BatchResult(success=False, error=str(e), retryable=False)
        except resend.exceptions.ResendError as e:
            if attempt == max_retries:
                return BatchResult(success=False, error=str(e), retryable=True)

            # Exponential backoff: 1s, 2s, 4s...
            delay = 2 ** attempt
            print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
            time.sleep(delay)

    return BatchResult(success=False, error="Max retries exceeded", retryable=True)

# Usage
result = send_batch_with_retry(
    emails=[
        {"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
        {"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
    ],
    idempotency_key=f"batch-welcome/{batch_id}"
)

if result.success:
    print(f"Batch sent: {result.data}")
else:
    print(f"Batch failed: {result.error}")
```

## Chunking Large Batches

For sends larger than 100 emails, chunk into multiple batch requests.

### Node.js

```typescript
import { Resend } from 'resend';
import { randomUUID } from 'crypto';

const resend = new Resend(process.env.RESEND_API_KEY);

const BATCH_SIZE = 100;

interface Email {
  from: string;
  to: string[];
  subject: string;
  html: string;
}

async function sendLargeBatch(emails: Email[], batchPrefix: string) {
  const chunks: Email[][] = [];

  for (let i = 0; i < emails.length; i += BATCH_SIZE) {
    chunks.push(emails.slice(i, i + BATCH_SIZE));
  }

  const results = await Promise.all(
    chunks.map(async (chunk, index) => {
      const idempotencyKey = `${batchPrefix}/chunk-${index}`;

      const { data, error } = await resend.batch.send(chunk, { idempotencyKey });

      return {
        chunkIndex: index,
        success: !error,
        data,
        error: error?.message,
      };
    })
  );

  const successful = results.filter(r => r.success);
  const failed = results.filter(r => !r.success);

  return {
    totalChunks: chunks.length,
    successful: successful.length,
    failed: failed.length,
    results,
  };
}

// Usage: Send 250 emails
const emails = generateEmails(250); // Your email generation logic
const result = await sendLargeBatch(emails, `campaign-${randomUUID()}`);

console.log(`Sent ${result.successful}/${result.totalChunks} chunks successfully`);
```

### Python

```python
import resend
import os
import uuid
from typing import List
from concurrent.futures import ThreadPoolExecutor, as_completed

resend.api_key = os.environ["RESEND_API_KEY"]

BATCH_SIZE = 100

def chunk_list(lst: List, size: int) -> List[List]:
    return [lst[i:i + size] for i in range(0, len(lst), size)]

def send_chunk(chunk: List[dict], idempotency_key: str) -> dict:
    try:
        result = resend.Batch.send(chunk, idempotency_key=idempotency_key)
        return {"success": True, "data": result}
    except Exception as e:
        return {"success": False, "error": str(e)}

def send_large_batch(emails: List[dict], batch_prefix: str) -> dict:
    chunks = chunk_list(emails, BATCH_SIZE)
    results = []

    # Send chunks in parallel (adjust max_workers as needed)
    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = {
            executor.submit(
                send_chunk,
                chunk,
                f"{batch_prefix}/chunk-{index}"
            ): index
            for index, chunk in enumerate(chunks)
        }

        for future in as_completed(futures):
            index = futures[future]
            result = future.result()
            result["chunk_index"] = index
            results.append(result)

    successful = [r for r in results if r["success"]]
    failed = [r for r in results if not r["success"]]

    return {
        "total_chunks": len(chunks),
        "successful": len(successful),
        "failed": len(failed),
        "results": results,
    }

# Usage: Send 250 emails
emails = generate_emails(250)  # Your email generation logic
result = send_large_batch(emails, f"campaign-{uuid.uuid4()}")

print(f"Sent {result['successful']}/{result['total_chunks']} chunks successfully")
```

## Production-Ready Implementations

### Node.js - Complete Batch Email Service

```typescript
import { Resend } from 'resend';
import { randomUUID } from 'crypto';

const resend = new Resend(process.env.RESEND_API_KEY);

interface BatchEmail {
  from: string;
  to: string[];
  subject: string;
  html?: string;
  text?: string;
  replyTo?: string;
  cc?: string[];
  bcc?: string[];
  tags?: { name: string; value: string }[];
}

interface BatchSendOptions {
  idempotencyKey?: string;
  maxRetries?: number;
  validateBeforeSend?: boolean;
}

interface BatchResult {
  success: boolean;
  data?: { id: string }[];
  error?: string;
  retryable: boolean;
  validationErrors?: { index: number; field: string; message: string }[];
}

class BatchEmailService {
  private maxBatchSize = 100;
  private maxRecipientsPerEmail = 50;

  validate(emails: BatchEmail[]): { valid: boolean; errors: BatchResult['validationErrors'] } {
    const errors: NonNullable<BatchResult['validationErrors']> = [];
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    if (emails.length === 0) {
      errors.push({ index: -1, field: 'batch', message: 'Batch cannot be empty' });
      return { valid: false, errors };
    }

    if (emails.length > this.maxBatchSize) {
      errors.push({ index: -1, field: 'batch', message: `Batch cannot exceed ${this.maxBatchSize} emails` });
      return { valid: false, errors };
    }

    emails.forEach((email, index) => {
      if (!email.from) {
        errors.push({ index, field: 'from', message: 'From address is required' });
      }

      if (!email.to?.length) {
        errors.push({ index, field: 'to', message: 'At least one recipient is required' });
      } else if (email.to.length > this.maxRecipientsPerEmail) {
        errors.push({ index, field: 'to', message: `Cannot exceed ${this.maxRecipientsPerEmail} recipients` });
      } else {
        email.to.forEach((r, i) => {
          if (!emailRegex.test(r)) {
            errors.push({ index, field: `to[${i}]`, message: `Invalid email: ${r}` });
          }
        });
      }

      if (!email.subject) {
        errors.push({ index, field: 'subject', message: 'Subject is required' });
      }

      if (!email.html && !email.text) {
        errors.push({ index, field: 'content', message: 'Either html or text is required' });
      }
    });

    return { valid: errors.length === 0, errors };
  }

  async send(emails: BatchEmail[], options: BatchSendOptions = {}): Promise<BatchResult> {
    const {
      idempotencyKey = `batch-${randomUUID()}`,
      maxRetries = 3,
      validateBeforeSend = true,
    } = options;

    // Validate first
    if (validateBeforeSend) {
      const validation = this.validate(emails);
      if (!validation.valid) {
        return {
          success: false,
          error: 'Validation failed',
          retryable: false,
          validationErrors: validation.errors,
        };
      }
    }

    // Send with retry
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      const { data, error } = await resend.batch.send(emails, { idempotencyKey });

      if (!error) {
        return { success: true, data, retryable: false };
      }

      // Non-retryable errors
      if (error.name === 'validation_error' || error.name === 'not_found') {
        return { success: false, error: error.message, retryable: false };
      }

      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise(r => setTimeout(r, delay));
      }
    }

    return { success: false, error: 'Max retries exceeded', retryable: true };
  }

  async sendLarge(emails: BatchEmail[], batchPrefix: string): Promise<{
    totalEmails: number;
    totalChunks: number;
    successfulChunks: number;
    failedChunks: number;
    sentEmailIds: string[];
    errors: { chunkIndex: number; error: string }[];
  }> {
    const chunks: BatchEmail[][] = [];
    for (let i = 0; i < emails.length; i += this.maxBatchSize) {
      chunks.push(emails.slice(i, i + this.maxBatchSize));
    }

    const results = await Promise.all(
      chunks.map((chunk, index) =>
        this.send(chunk, { idempotencyKey: `${batchPrefix}/chunk-${index}` })
          .then(result => ({ chunkIndex: index, ...result }))
      )
    );

    const successful = results.filter(r => r.success);
    const failed = results.filter(r => !r.success);

    return {
      totalEmails: emails.length,
      totalChunks: chunks.length,
      successfulChunks: successful.length,
      failedChunks: failed.length,
      sentEmailIds: successful.flatMap(r => r.data?.map(d => d.id) || []),
      errors: failed.map(r => ({ chunkIndex: r.chunkIndex, error: r.error || 'Unknown error' })),
    };
  }
}

// Usage
const batchService = new BatchEmailService();

// Simple batch
const result = await batchService.send([
  { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
  { from: 'Acme <[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '<p>Hi</p>' },
], { idempotencyKey: `welcome-batch/${batchId}` });

// Large batch (auto-chunked)
const largeResult = await batchService.sendLarge(emails, `campaign-${campaignId}`);
```

### Python - Complete Batch Email Service

```python
import resend
import os
import re
import time
import uuid
from dataclasses import dataclass, field
from typing import List, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed

resend.api_key = os.environ["RESEND_API_KEY"]

@dataclass
class ValidationError:
    index: int
    field: str
    message: str

@dataclass
class BatchResult:
    success: bool
    data: Optional[List[dict]] = None
    error: Optional[str] = None
    retryable: bool = False
    validation_errors: List[ValidationError] = field(default_factory=list)

@dataclass
class LargeBatchResult:
    total_emails: int
    total_chunks: int
    successful_chunks: int
    failed_chunks: int
    sent_email_ids: List[str]
    errors: List[dict]

class BatchEmailService:
    MAX_BATCH_SIZE = 100
    MAX_RECIPIENTS_PER_EMAIL = 50

    def __init__(self):
        self.email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')

    def validate(self, emails: List[dict]) -> tuple[bool, List[ValidationError]]:
        errors = []

        if not emails:
            errors.append(ValidationError(-1, 'batch', 'Batch cannot be empty'))
            return False, errors

        if len(emails) > self.MAX_BATCH_SIZE:
            errors.append(ValidationError(-1, 'batch', f'Batch cannot exceed {self.MAX_BATCH_SIZE} emails'))
            return False, errors

        for index, email in enumerate(emails):
            if not email.get('from'):
                errors.append(ValidationError(index, 'from', 'From address is required'))

            to_list = email.get('to', [])
            if not to_list:
                errors.append(ValidationError(index, 'to', 'At least one recipient is required'))
            elif len(to_list) > self.MAX_RECIPIENTS_PER_EMAIL:
                errors.append(ValidationError(index, 'to', f'Cannot exceed {self.MAX_RECIPIENTS_PER_EMAIL} recipients'))
            else:
                for r_idx, recipient in enumerate(to_list):
                    if not self.email_regex.match(recipient):
                        errors.append(ValidationError(index, f'to[{r_idx}]', f'Invalid email: {recipient}'))

            if not email.get('subject'):
                errors.append(ValidationError(index, 'subject', 'Subject is required'))

            if not email.get('html') and not email.get('text'):
                errors.append(ValidationError(index, 'content', 'Either html or text is required'))

        return len(errors) == 0, errors

    def send(
        self,
        emails: List[dict],
        idempotency_key: Optional[str] = None,
        max_retries: int = 3,
        validate_before_send: bool = True
    ) -> BatchResult:
        idempotency_key = idempotency_key or f"batch-{uuid.uuid4()}"

        # Validate first
        if validate_before_send:
            valid, errors = self.validate(emails)
            if not valid:
                return BatchResult(
                    success=False,
                    error='Validation failed',
                    retryable=False,
                    validation_errors=errors
                )

        # Send with retry
        for attempt in range(max_retries + 1):
            try:
                result = resend.Batch.send(emails, idempotency_key=idempotency_key)
                return BatchResult(success=True, data=result)
            except resend.exceptions.ValidationError as e:
                return BatchResult(success=False, error=str(e), retryable=False)
            except resend.exceptions.ResendError as e:
                if attempt < max_retries:
                    time.sleep(2 ** attempt)
                else:
                    return BatchResult(success=False, error=str(e), retryable=True)

        return BatchResult(success=False, error='Max retries exceeded', retryable=True)

    def send_large(self, emails: List[dict], batch_prefix: str, max_workers: int = 5) -> LargeBatchResult:
        chunks = [emails[i:i + self.MAX_BATCH_SIZE] for i in range(0, len(emails), self.MAX_BATCH_SIZE)]
        results = []

        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {
                executor.submit(
                    self.send,
                    chunk,
                    f"{batch_prefix}/chunk-{idx}"
                ): idx
                for idx, chunk in enumerate(chunks)
            }

            for future in as_completed(futures):
                idx = futures[future]
                result = future.result()
                results.append({"chunk_index": idx, "result": result})

        successful = [r for r in results if r["result"].success]
        failed = [r for r in results if not r["result"].success]

        return LargeBatchResult(
            total_emails=len(emails),
            total_chunks=len(chunks),
            successful_chunks=len(successful),
            failed_chunks=len(failed),
            sent_email_ids=[
                item["id"]
                for r in successful
                if r["result"].data
                for item in r["result"].data
            ],
            errors=[
                {"chunk_index": r["chunk_index"], "error": r["result"].error}
                for r in failed
            ]
        )

# Usage
service = BatchEmailService()

# Simple batch
result = service.send([
    {"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
    {"from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Hello", "html": "<p>Hi</p>"},
], idempotency_key=f"welcome-batch/{batch_id}")

# Large batch (auto-chunked)
large_result = service.send_large(emails, f"campaign-{campaign_id}")
```

```

### references/webhooks.md

```markdown
# Webhooks

Receive real-time notifications when email events occur (delivered, bounced, opened, etc.).

## When to Use Webhooks

- Track delivery status in your database
- Remove bounced addresses from mailing lists
- Trigger follow-up actions when emails are opened/clicked
- Create alerts for failures or complaints
- Build custom analytics dashboards

## Event Types

### Email Events

| Event | Trigger | Use Case |
|-------|---------|----------|
| `email.sent` | API request successful, delivery attempted | Confirm email accepted |
| `email.delivered` | Email reached recipient's mail server | Confirm successful delivery |
| `email.bounced` | Mail server permanently rejected (hard bounce) | Remove from list, alert user |
| `email.complained` | Recipient marked as spam | Unsubscribe, review content |
| `email.opened` | Recipient opened email | Track engagement |
| `email.clicked` | Recipient clicked a link | Track engagement |
| `email.delivery_delayed` | Temporary delivery issue (soft bounce) | Monitor, may resolve automatically |
| `email.failed` | Send error (invalid recipient, quota, etc.) | Debug, alert |

### Bounce Types

| Type | Event | Action |
|------|-------|--------|
| **Hard bounce (Permanent)** | `email.bounced` | Remove address immediately - never retry |
| **Soft bounce (Transient)** | `email.delivery_delayed` | Monitor - Resend retries automatically |
| **Undetermined** | `email.bounced` | Treat as hard bounce if repeated |

**Hard bounces** (`email.bounced`) are permanent failures. The address is invalid and will never accept mail. Continuing to send to hard-bounced addresses destroys your sender reputation.

| Subtype | Cause |
|---------|-------|
| General | Recipient's email provider sent a hard bounce |
| NoEmail | Address doesn't exist or couldn't be determined |

**Soft bounces** (`email.delivery_delayed`) are temporary issues. Resend automatically retries these. If delivery ultimately fails after retries, you'll receive an `email.bounced` event.

| Subtype | Cause | May Resolve If... |
|---------|-------|-------------------|
| General | Temporary rejection | Underlying issue clears |
| MailboxFull | Recipient's inbox at capacity | Recipient frees space |
| MessageTooLarge | Exceeds provider size limit | You reduce message size |
| ContentRejected | Contains disallowed content | You modify content |
| AttachmentRejected | Attachment type/size rejected | You modify attachment |

**Undetermined bounces** occur when the bounce message doesn't contain enough information for Resend to determine the reason. Treat repeated undetermined bounces as hard bounces.

### Other Events

| Event | Trigger |
|-------|---------|
| `domain.created` / `updated` / `deleted` | Domain configuration changes |
| `contact.created` / `updated` / `deleted` | Contact list changes (not from CSV imports) |

## Setup

1. **Create endpoint** - POST endpoint that returns HTTP 200
2. **Add webhook** - In Resend dashboard (resend.com/webhooks), add your URL and select events
3. **Verify signatures** - **REQUIRED** - See [Signature Verification](#signature-verification)
4. **Test locally** - Use ngrok or similar for local development

## Signature Verification

**You MUST verify webhook signatures.** Without verification, attackers can send fake webhooks to your endpoint.

### Why Verification Matters

- Webhooks are unauthenticated HTTP POST requests
- Anyone who knows your endpoint URL can send fake events
- Verification ensures the webhook genuinely came from Resend
- Unique signatures prevent replay attacks

### Required Headers

Every webhook includes these headers for verification:

| Header | Purpose |
|--------|---------|
| `svix-id` | Unique message identifier |
| `svix-timestamp` | Unix timestamp when sent |
| `svix-signature` | Cryptographic signature |

### Get Your Webhook Secret

Find your signing secret in the Resend dashboard:
1. Go to resend.com/webhooks
2. Click on your webhook
3. Copy the signing secret (starts with `whsec_`)

Store it securely as `RESEND_WEBHOOK_SECRET` environment variable.

### Using Resend SDK (Recommended)

Example using Next.js:

```typescript
import { Resend } from 'resend';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; // Your database client (Prisma, Drizzle, etc.)

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: NextRequest) {
  try {
    // CRITICAL: Use raw body, not parsed JSON
    const payload = await req.text();

    // Throws an error if the webhook is invalid
    // Otherwise, returns the parsed payload object
    const event = resend.webhooks.verify({
      payload,
      headers: {
        'svix-id': req.headers.get('svix-id'),
        'svix-timestamp': req.headers.get('svix-timestamp'),
        'svix-signature': req.headers.get('svix-signature'),
      },
      secret: process.env.RESEND_WEBHOOK_SECRET,
    });

    // Handle the verified event
    switch (event.type) {
      case 'email.delivered':
        // update database with the email delivery status
        break;

      case 'email.bounced':
        // Hard bounce - remove from mailing list immediately
        break;

      case 'email.complained':
        // Spam complaint - unsubscribe and flag
        break;

      default:
        // handle other events
        return new Response('OK', { status: 200, body: 'Event skipped' });
    }

    return new NextResponse('OK', { status: 200 });
  } catch (error) {
    console.error('Webhook verification failed:', error);
    return new NextResponse('Invalid signature', { status: 400 });
  }
}
```

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Not verifying signatures | **Always verify** - attackers can send fake webhooks |
| Using parsed JSON body | Use raw request body - JSON parsing breaks signature |
| Using `express.json()` middleware | Use `express.raw()` for webhook routes |
| Hardcoding webhook secret | Store in environment variable |
| Returning non-200 status for valid webhooks | Return 200 OK to acknowledge receipt |

## Retry Schedule

If your endpoint doesn't return HTTP 200, Resend retries with exponential backoff:

| Attempt | Delay After Failure |
|---------|---------------------|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |

Example: A webhook that fails 3 times before succeeding will be delivered ~35 minutes after the first attempt.

**Tip:** Always return 200 quickly, then process asynchronously if needed. You can manually replay failed webhooks from the dashboard.

## IP Allowlist

If your firewall requires allowlisting, webhooks come from:

```
44.228.126.217
50.112.21.217
52.24.126.164
54.148.139.208
```

IPv6: `2600:1f24:64:8000::/52`

## Local Development

Use tunneling tools to test webhooks locally:

```bash
# ngrok
ngrok http 3000

# use the port that your server is running on (e.g. 3000)
# Then use the ngrok URL in Resend dashboard
# https://abc123.ngrok.io/webhooks/resend
```

## Event Payload Example

```json
{
  "type": "email.delivered",
  "created_at": "2024-01-15T12:00:00.000Z",
  "data": {
    "email_id": "ae2014de-c168-4c61-8267-70d2662a1ce1",
    "from": "Acme <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Welcome to Acme"
  }
}
```

```

send-email | SkillHub