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.
Install command
npx @skill-hub/cli install resend-resend-skills-send-email
Repository
Skill path: send-email
Use when sending transactional emails (welcome messages, order confirmations, password resets, receipts), notifications, or bulk emails via Resend API.
Open repositoryBest 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
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" } } ``` ```