READ-ONLY PACKAGE PREVIEW

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