resend/references/sending/best-practices.md
Version edbfece3f402.bb1 · MIT. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.
← Return to resource and package checksum
Best Practices for Sending Emails with Resend
Table of Contents
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:
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
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:
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
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
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
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
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
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
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
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 for complete validation implementations.
Chunking Large Batches
For sends larger than 100 emails, chunk into multiple batch requests with unique idempotency keys per chunk.
// 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 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