Resend Quick Send — Node.js Key gotcha: The Resend Node.js SDK does NOT throw exceptions — it returns . Always check explicitly instead of using try/catch for API errors. Quick Send — Python Single vs Batch Decision | Choose | When | |--------|------| | Single ( ) | 1 email, needs attachments, needs scheduling | | Batch ( ) | 2-100 distinct emails, no attachments, no scheduling | Batch is atomic — if one email fails validation, the entire batch fails. Always validate before sending. Batch does NOT support attachments or . Idempotency Keys (Critical for Retries) Prevent duplicate emails when r…

)\n\n if not emails:\n return False, [ValidationError(-1, 'batch', 'Batch cannot be empty')]\n\n if len(emails) > 100:\n return False, [ValidationError(-1, 'batch', 'Batch cannot exceed 100 emails')]\n\n for index, email in enumerate(emails):\n if not email.get('from'):\n errors.append(ValidationError(index, 'from', 'From address is required'))\n\n to_list = email.get('to', [])\n if not to_list:\n errors.append(ValidationError(index, 'to', 'At least one recipient is required'))\n elif len(to_list) > 50:\n errors.append(ValidationError(index, 'to', 'Cannot exceed 50 recipients per email'))\n else:\n for r_index, recipient in enumerate(to_list):\n if not email_regex.match(recipient):\n errors.append(ValidationError(index, f'to[{r_index}]', f'Invalid email: {recipient}'))\n\n if not email.get('subject'):\n errors.append(ValidationError(index, 'subject', 'Subject is required'))\n\n if not email.get('html') and not email.get('text'):\n errors.append(ValidationError(index, 'content', 'Either html or text content is required'))\n\n return len(errors) == 0, errors\n\n# Usage\nemails = [\n {\"from\": \"Acme \[email protected]>\", \"to\": [\"[email protected]\"], \"subject\": \"Hello\", \"html\": \"\u003cp>Hi\u003c/p>\"},\n]\n\nvalid, errors = validate_batch(emails)\nif not valid:\n print(f\"Validation failed: {errors}\")\nelse:\n result = resend.Batch.send(emails)\n```\n\n## Error Handling\n\n### Common Error Codes\n\n| Code | Description | Action |\n|------|-------------|--------|\n| 400 | Bad request (invalid params) | Fix request parameters, don't retry |\n| 401 | Invalid API key | Check RESEND_API_KEY, don't retry |\n| 403 | Domain not verified | Verify domain at resend.com/domains |\n| 409 | Idempotency conflict | Same key with different payload |\n| 422 | Unprocessable entity | Check email format/content, don't retry |\n| 429 | Rate limited | Retry with exponential backoff |\n| 500 | Server error | Retry with exponential backoff |\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.batch.send(emails);\n\nif (error) {\n switch (error.name) {\n case 'validation_error':\n // Invalid parameters - don't retry, fix the data\n console.error('Validation error:', error.message);\n throw new Error(`Invalid batch data: ${error.message}`);\n\n case 'rate_limit_exceeded':\n // Rate limited - safe to retry with backoff\n console.log('Rate limited, should retry with backoff');\n break;\n\n case 'api_error':\n // Server error - safe to retry\n console.log('Server error, should retry');\n break;\n\n default:\n console.error('Unexpected error:', error);\n }\n return;\n}\n\nconsole.log('Batch sent successfully:', data);\n```\n\n### Python\n\n```python\nimport resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\ntry:\n result = resend.Batch.send(emails)\n print(f\"Batch sent: {result}\")\nexcept resend.exceptions.ValidationError as e:\n # Invalid parameters - don't retry\n print(f\"Validation error (don't retry): {e}\")\n raise\nexcept resend.exceptions.RateLimitError as e:\n # Rate limited - retry with backoff\n print(f\"Rate limited (retry with backoff): {e}\")\nexcept resend.exceptions.ResendError as e:\n # Other API error - may retry\n print(f\"API error: {e}\")\n```\n\n## Retry Logic with Idempotency\n\nCombine retry logic with idempotency keys to safely retry failed batches.\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\ninterface BatchSendOptions {\n maxRetries?: number;\n idempotencyKey: string;\n}\n\nasync function sendBatchWithRetry(\n emails: Parameters\u003ctypeof resend.batch.send>[0],\n options: BatchSendOptions\n) {\n const { maxRetries = 3, idempotencyKey } = options;\n\n for (let attempt = 0; attempt \u003c= maxRetries; attempt++) {\n const { data, error } = await resend.batch.send(emails, {\n idempotencyKey,\n });\n\n if (!error) {\n return { success: true, data };\n }\n\n // Don't retry validation errors\n if (error.name === 'validation_error') {\n return { success: false, error: error.message, retryable: false };\n }\n\n // Don't retry idempotency conflicts\n if (error.name === 'idempotency_error') {\n return { success: false, error: 'Duplicate request with different payload', retryable: false };\n }\n\n // Last attempt failed\n if (attempt === maxRetries) {\n return { success: false, error: error.message, retryable: true };\n }\n\n // Exponential backoff: 1s, 2s, 4s...\n const delay = Math.pow(2, attempt) * 1000;\n console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n\n return { success: false, error: 'Max retries exceeded', retryable: true };\n}\n\n// Usage\nconst result = await sendBatchWithRetry(\n [\n { from: 'Acme \[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '\u003cp>Hi\u003c/p>' },\n { from: 'Acme \[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '\u003cp>Hi\u003c/p>' },\n ],\n { idempotencyKey: `batch-welcome/${batchId}` }\n);\n\nif (result.success) {\n console.log('Batch sent:', result.data);\n} else {\n console.error('Batch failed:', result.error);\n}\n```\n\n### Python\n\n```python\nimport resend\nimport os\nimport time\nfrom dataclasses import dataclass\nfrom typing import Optional, List\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\n@dataclass\nclass BatchResult:\n success: bool\n data: Optional[List[dict]] = None\n error: Optional[str] = None\n retryable: bool = False\n\ndef send_batch_with_retry(\n emails: List[dict],\n idempotency_key: str,\n max_retries: int = 3\n) -> BatchResult:\n for attempt in range(max_retries + 1):\n try:\n result = resend.Batch.send(emails, idempotency_key=idempotency_key)\n return BatchResult(success=True, data=result)\n except resend.exceptions.ValidationError as e:\n # Don't retry validation errors\n return BatchResult(success=False, error=str(e), retryable=False)\n except resend.exceptions.ResendError as e:\n if attempt == max_retries:\n return BatchResult(success=False, error=str(e), retryable=True)\n\n # Exponential backoff: 1s, 2s, 4s...\n delay = 2 ** attempt\n print(f\"Attempt {attempt + 1} failed, retrying in {delay}s...\")\n time.sleep(delay)\n\n return BatchResult(success=False, error=\"Max retries exceeded\", retryable=True)\n\n# Usage\nresult = send_batch_with_retry(\n emails=[\n {\"from\": \"Acme \[email protected]>\", \"to\": [\"[email protected]\"], \"subject\": \"Hello\", \"html\": \"\u003cp>Hi\u003c/p>\"},\n {\"from\": \"Acme \[email protected]>\", \"to\": [\"[email protected]\"], \"subject\": \"Hello\", \"html\": \"\u003cp>Hi\u003c/p>\"},\n ],\n idempotency_key=f\"batch-welcome/{batch_id}\"\n)\n\nif result.success:\n print(f\"Batch sent: {result.data}\")\nelse:\n print(f\"Batch failed: {result.error}\")\n```\n\n## Chunking Large Batches\n\nFor sends larger than 100 emails, chunk into multiple batch requests.\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\nimport { randomUUID } from 'crypto';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst BATCH_SIZE = 100;\n\ninterface Email {\n from: string;\n to: string[];\n subject: string;\n html: string;\n}\n\nasync function sendLargeBatch(emails: Email[], batchPrefix: string) {\n const chunks: Email[][] = [];\n\n for (let i = 0; i \u003c emails.length; i += BATCH_SIZE) {\n chunks.push(emails.slice(i, i + BATCH_SIZE));\n }\n\n const results = await Promise.all(\n chunks.map(async (chunk, index) => {\n const idempotencyKey = `${batchPrefix}/chunk-${index}`;\n\n const { data, error } = await resend.batch.send(chunk, { idempotencyKey });\n\n return {\n chunkIndex: index,\n success: !error,\n data,\n error: error?.message,\n };\n })\n );\n\n const successful = results.filter(r => r.success);\n const failed = results.filter(r => !r.success);\n\n return {\n totalChunks: chunks.length,\n successful: successful.length,\n failed: failed.length,\n results,\n };\n}\n\n// Usage: Send 250 emails\nconst emails = generateEmails(250); // Your email generation logic\nconst result = await sendLargeBatch(emails, `campaign-${randomUUID()}`);\n\nconsole.log(`Sent ${result.successful}/${result.totalChunks} chunks successfully`);\n```\n\n### Python\n\n```python\nimport resend\nimport os\nimport uuid\nfrom typing import List\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\nBATCH_SIZE = 100\n\ndef chunk_list(lst: List, size: int) -> List[List]:\n return [lst[i:i + size] for i in range(0, len(lst), size)]\n\ndef send_chunk(chunk: List[dict], idempotency_key: str) -> dict:\n try:\n result = resend.Batch.send(chunk, idempotency_key=idempotency_key)\n return {\"success\": True, \"data\": result}\n except Exception as e:\n return {\"success\": False, \"error\": str(e)}\n\ndef send_large_batch(emails: List[dict], batch_prefix: str) -> dict:\n chunks = chunk_list(emails, BATCH_SIZE)\n results = []\n\n # Send chunks in parallel (adjust max_workers as needed)\n with ThreadPoolExecutor(max_workers=5) as executor:\n futures = {\n executor.submit(\n send_chunk,\n chunk,\n f\"{batch_prefix}/chunk-{index}\"\n ): index\n for index, chunk in enumerate(chunks)\n }\n\n for future in as_completed(futures):\n index = futures[future]\n result = future.result()\n result[\"chunk_index\"] = index\n results.append(result)\n\n successful = [r for r in results if r[\"success\"]]\n failed = [r for r in results if not r[\"success\"]]\n\n return {\n \"total_chunks\": len(chunks),\n \"successful\": len(successful),\n \"failed\": len(failed),\n \"results\": results,\n }\n\n# Usage: Send 250 emails\nemails = generate_emails(250) # Your email generation logic\nresult = send_large_batch(emails, f\"campaign-{uuid.uuid4()}\")\n\nprint(f\"Sent {result['successful']}/{result['total_chunks']} chunks successfully\")\n```\n\n## Production-Ready Implementations\n\n### Node.js - Complete Batch Email Service\n\n```typescript\nimport { Resend } from 'resend';\nimport { randomUUID } from 'crypto';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\ninterface BatchEmail {\n from: string;\n to: string[];\n subject: string;\n html?: string;\n text?: string;\n replyTo?: string;\n cc?: string[];\n bcc?: string[];\n tags?: { name: string; value: string }[];\n}\n\ninterface BatchSendOptions {\n idempotencyKey?: string;\n maxRetries?: number;\n validateBeforeSend?: boolean;\n}\n\ninterface BatchResult {\n success: boolean;\n data?: { id: string }[];\n error?: string;\n retryable: boolean;\n validationErrors?: { index: number; field: string; message: string }[];\n}\n\nclass BatchEmailService {\n private maxBatchSize = 100;\n private maxRecipientsPerEmail = 50;\n\n validate(emails: BatchEmail[]): { valid: boolean; errors: BatchResult['validationErrors'] } {\n const errors: NonNullable\u003cBatchResult['validationErrors']> = [];\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n if (emails.length === 0) {\n errors.push({ index: -1, field: 'batch', message: 'Batch cannot be empty' });\n return { valid: false, errors };\n }\n\n if (emails.length > this.maxBatchSize) {\n errors.push({ index: -1, field: 'batch', message: `Batch cannot exceed ${this.maxBatchSize} emails` });\n return { valid: false, errors };\n }\n\n emails.forEach((email, index) => {\n if (!email.from) {\n errors.push({ index, field: 'from', message: 'From address is required' });\n }\n\n if (!email.to?.length) {\n errors.push({ index, field: 'to', message: 'At least one recipient is required' });\n } else if (email.to.length > this.maxRecipientsPerEmail) {\n errors.push({ index, field: 'to', message: `Cannot exceed ${this.maxRecipientsPerEmail} recipients` });\n } else {\n email.to.forEach((r, i) => {\n if (!emailRegex.test(r)) {\n errors.push({ index, field: `to[${i}]`, message: `Invalid email: ${r}` });\n }\n });\n }\n\n if (!email.subject) {\n errors.push({ index, field: 'subject', message: 'Subject is required' });\n }\n\n if (!email.html && !email.text) {\n errors.push({ index, field: 'content', message: 'Either html or text is required' });\n }\n });\n\n return { valid: errors.length === 0, errors };\n }\n\n async send(emails: BatchEmail[], options: BatchSendOptions = {}): Promise\u003cBatchResult> {\n const {\n idempotencyKey = `batch-${randomUUID()}`,\n maxRetries = 3,\n validateBeforeSend = true,\n } = options;\n\n // Validate first\n if (validateBeforeSend) {\n const validation = this.validate(emails);\n if (!validation.valid) {\n return {\n success: false,\n error: 'Validation failed',\n retryable: false,\n validationErrors: validation.errors,\n };\n }\n }\n\n // Send with retry\n for (let attempt = 0; attempt \u003c= maxRetries; attempt++) {\n const { data, error } = await resend.batch.send(emails, { idempotencyKey });\n\n if (!error) {\n return { success: true, data, retryable: false };\n }\n\n // Non-retryable errors\n if (error.name === 'validation_error' || error.name === 'not_found') {\n return { success: false, error: error.message, retryable: false };\n }\n\n if (attempt \u003c maxRetries) {\n const delay = Math.pow(2, attempt) * 1000;\n await new Promise(r => setTimeout(r, delay));\n }\n }\n\n return { success: false, error: 'Max retries exceeded', retryable: true };\n }\n\n async sendLarge(emails: BatchEmail[], batchPrefix: string): Promise\u003c{\n totalEmails: number;\n totalChunks: number;\n successfulChunks: number;\n failedChunks: number;\n sentEmailIds: string[];\n errors: { chunkIndex: number; error: string }[];\n }> {\n const chunks: BatchEmail[][] = [];\n for (let i = 0; i \u003c emails.length; i += this.maxBatchSize) {\n chunks.push(emails.slice(i, i + this.maxBatchSize));\n }\n\n const results = await Promise.all(\n chunks.map((chunk, index) =>\n this.send(chunk, { idempotencyKey: `${batchPrefix}/chunk-${index}` })\n .then(result => ({ chunkIndex: index, ...result }))\n )\n );\n\n const successful = results.filter(r => r.success);\n const failed = results.filter(r => !r.success);\n\n return {\n totalEmails: emails.length,\n totalChunks: chunks.length,\n successfulChunks: successful.length,\n failedChunks: failed.length,\n sentEmailIds: successful.flatMap(r => r.data?.map(d => d.id) || []),\n errors: failed.map(r => ({ chunkIndex: r.chunkIndex, error: r.error || 'Unknown error' })),\n };\n }\n}\n\n// Usage\nconst batchService = new BatchEmailService();\n\n// Simple batch\nconst result = await batchService.send([\n { from: 'Acme \[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '\u003cp>Hi\u003c/p>' },\n { from: 'Acme \[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '\u003cp>Hi\u003c/p>' },\n], { idempotencyKey: `welcome-batch/${batchId}` });\n\n// Large batch (auto-chunked)\nconst largeResult = await batchService.sendLarge(emails, `campaign-${campaignId}`);\n```\n\n### Python - Complete Batch Email Service\n\n```python\nimport resend\nimport os\nimport re\nimport time\nimport uuid\nfrom dataclasses import dataclass, field\nfrom typing import List, Optional\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\n@dataclass\nclass ValidationError:\n index: int\n field: str\n message: str\n\n@dataclass\nclass BatchResult:\n success: bool\n data: Optional[List[dict]] = None\n error: Optional[str] = None\n retryable: bool = False\n validation_errors: List[ValidationError] = field(default_factory=list)\n\n@dataclass\nclass LargeBatchResult:\n total_emails: int\n total_chunks: int\n successful_chunks: int\n failed_chunks: int\n sent_email_ids: List[str]\n errors: List[dict]\n\nclass BatchEmailService:\n MAX_BATCH_SIZE = 100\n MAX_RECIPIENTS_PER_EMAIL = 50\n\n def __init__(self):\n self.email_regex = re.compile(r'^[^\\s@]+@[^\\s@]+\\.[^\\s@]+

Resend Quick Send — Node.js Key gotcha: The Resend Node.js SDK does NOT throw exceptions — it returns . Always check explicitly instead of using try/catch for API errors. Quick Send — Python Single vs Batch Decision | Choose | When | |--------|------| | Single ( ) | 1 email, needs attachments, needs scheduling | | Batch ( ) | 2-100 distinct emails, no attachments, no scheduling | Batch is atomic — if one email fails validation, the entire batch fails. Always validate before sending. Batch does NOT support attachments or . Idempotency Keys (Critical for Retries) Prevent duplicate emails when r…

)\n\n def validate(self, emails: List[dict]) -> tuple[bool, List[ValidationError]]:\n errors = []\n\n if not emails:\n errors.append(ValidationError(-1, 'batch', 'Batch cannot be empty'))\n return False, errors\n\n if len(emails) > self.MAX_BATCH_SIZE:\n errors.append(ValidationError(-1, 'batch', f'Batch cannot exceed {self.MAX_BATCH_SIZE} emails'))\n return False, errors\n\n for index, email in enumerate(emails):\n if not email.get('from'):\n errors.append(ValidationError(index, 'from', 'From address is required'))\n\n to_list = email.get('to', [])\n if not to_list:\n errors.append(ValidationError(index, 'to', 'At least one recipient is required'))\n elif len(to_list) > self.MAX_RECIPIENTS_PER_EMAIL:\n errors.append(ValidationError(index, 'to', f'Cannot exceed {self.MAX_RECIPIENTS_PER_EMAIL} recipients'))\n else:\n for r_idx, recipient in enumerate(to_list):\n if not self.email_regex.match(recipient):\n errors.append(ValidationError(index, f'to[{r_idx}]', f'Invalid email: {recipient}'))\n\n if not email.get('subject'):\n errors.append(ValidationError(index, 'subject', 'Subject is required'))\n\n if not email.get('html') and not email.get('text'):\n errors.append(ValidationError(index, 'content', 'Either html or text is required'))\n\n return len(errors) == 0, errors\n\n def send(\n self,\n emails: List[dict],\n idempotency_key: Optional[str] = None,\n max_retries: int = 3,\n validate_before_send: bool = True\n ) -> BatchResult:\n idempotency_key = idempotency_key or f\"batch-{uuid.uuid4()}\"\n\n # Validate first\n if validate_before_send:\n valid, errors = self.validate(emails)\n if not valid:\n return BatchResult(\n success=False,\n error='Validation failed',\n retryable=False,\n validation_errors=errors\n )\n\n # Send with retry\n for attempt in range(max_retries + 1):\n try:\n result = resend.Batch.send(emails, idempotency_key=idempotency_key)\n return BatchResult(success=True, data=result)\n except resend.exceptions.ValidationError as e:\n return BatchResult(success=False, error=str(e), retryable=False)\n except resend.exceptions.ResendError as e:\n if attempt \u003c max_retries:\n time.sleep(2 ** attempt)\n else:\n return BatchResult(success=False, error=str(e), retryable=True)\n\n return BatchResult(success=False, error='Max retries exceeded', retryable=True)\n\n def send_large(self, emails: List[dict], batch_prefix: str, max_workers: int = 5) -> LargeBatchResult:\n chunks = [emails[i:i + self.MAX_BATCH_SIZE] for i in range(0, len(emails), self.MAX_BATCH_SIZE)]\n results = []\n\n with ThreadPoolExecutor(max_workers=max_workers) as executor:\n futures = {\n executor.submit(\n self.send,\n chunk,\n f\"{batch_prefix}/chunk-{idx}\"\n ): idx\n for idx, chunk in enumerate(chunks)\n }\n\n for future in as_completed(futures):\n idx = futures[future]\n result = future.result()\n results.append({\"chunk_index\": idx, \"result\": result})\n\n successful = [r for r in results if r[\"result\"].success]\n failed = [r for r in results if not r[\"result\"].success]\n\n return LargeBatchResult(\n total_emails=len(emails),\n total_chunks=len(chunks),\n successful_chunks=len(successful),\n failed_chunks=len(failed),\n sent_email_ids=[\n item[\"id\"]\n for r in successful\n if r[\"result\"].data\n for item in r[\"result\"].data\n ],\n errors=[\n {\"chunk_index\": r[\"chunk_index\"], \"error\": r[\"result\"].error}\n for r in failed\n ]\n )\n\n# Usage\nservice = BatchEmailService()\n\n# Simple batch\nresult = service.send([\n {\"from\": \"Acme \[email protected]>\", \"to\": [\"[email protected]\"], \"subject\": \"Hello\", \"html\": \"\u003cp>Hi\u003c/p>\"},\n {\"from\": \"Acme \[email protected]>\", \"to\": [\"[email protected]\"], \"subject\": \"Hello\", \"html\": \"\u003cp>Hi\u003c/p>\"},\n], idempotency_key=f\"welcome-batch/{batch_id}\")\n\n# Large batch (auto-chunked)\nlarge_result = service.send_large(emails, f\"campaign-{campaign_id}\")\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":24242,"content_sha256":"6d9bc7ceed7ecf656a4eb8e19b3878e15793ede7e57bcb7b9505b1ee095ac250"},{"filename":"references/sending/best-practices.md","content":"# Best Practices for Sending Emails with Resend\n\n## Table of Contents\n\n- [Idempotency Keys](#idempotency-keys)\n- [Error Handling](#error-handling)\n- [Retry Logic](#retry-logic)\n- [Batch-Specific Practices](#batch-specific-practices)\n\n## Idempotency Keys\n\nUse idempotency keys to prevent duplicate emails when retrying failed requests.\n\n### Key Facts\n\n- **Expiration:** Keys expire after 24 hours\n- **Max length:** 256 characters\n- **Format:** Use `\u003cevent-type>/\u003centity-id>` pattern for single emails, `batch-\u003cevent-type>/\u003cbatch-id>` for batch\n- **Behavior:** Same key + same payload = returns original response without resending\n- **Conflict:** Same key + different payload = returns 409 error\n\n### Examples by Format\n\n| Use Case | Key Format | Example |\n|----------|------------|---------|\n| Welcome email | `welcome-email/\u003cuser-id>` | `welcome-email/user-123` |\n| Order confirmation | `order-confirmation/\u003corder-id>` | `order-confirmation/order-456` |\n| Password reset | `password-reset/\u003cuser-id>/\u003ctimestamp>` | `password-reset/user-123/1705123456` |\n| Batch notifications | `batch-\u003cevent>/\u003cbatch-id>` | `batch-order-notifications/batch-789` |\n| Large batch chunk | `\u003cbatch-prefix>/chunk-\u003cindex>` | `campaign-abc/chunk-0` |\n\n### Node.js\n\nThe Node.js SDK has a dedicated `idempotencyKey` option:\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\n// Single email\nconst { data, error } = await resend.emails.send(\n {\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Order Confirmation',\n html: '\u003cp>Your order has been confirmed.\u003c/p>',\n },\n {\n idempotencyKey: `order-confirmation/${orderId}`,\n }\n);\n\n// Batch email\nconst { data, error } = await resend.batch.send(\n [\n { from: 'Acme \[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '\u003cp>Hi\u003c/p>' },\n { from: 'Acme \[email protected]>', to: ['[email protected]'], subject: 'Hello', html: '\u003cp>Hi\u003c/p>' },\n ],\n { idempotencyKey: `batch-welcome/${batchId}` }\n);\n```\n\n### Python\n\n```python\nimport resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\n# Single email\nemail = resend.Emails.send({\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Order Confirmation\",\n \"html\": \"\u003cp>Your order has been confirmed.\u003c/p>\",\n}, idempotency_key=f\"order-confirmation/{order_id}\")\n\n# Batch email\nresult = resend.Batch.send(emails, idempotency_key=f\"batch-orders/{batch_id}\")\n```\n\n### Go\n\nOther SDKs use the `Idempotency-Key` header:\n\n```go\nimport \"github.com/resend/resend-go/v4\"\n\nclient := resend.NewClient(os.Getenv(\"RESEND_API_KEY\"))\n\n// Single email\nparams := &resend.SendEmailRequest{\n From: \"Acme \[email protected]>\",\n To: []string{\"[email protected]\"},\n Subject: \"Order Confirmation\",\n Html: \"\u003cp>Your order has been confirmed.\u003c/p>\",\n Headers: map[string]string{\n \"Idempotency-Key\": fmt.Sprintf(\"order-confirmation/%s\", orderID),\n },\n}\n\nsent, err := client.Emails.Send(params)\n```\n\n### cURL\n\n```bash\ncurl -X POST 'https://api.resend.com/emails' \\\n -H 'Authorization: Bearer re_xxxxxxxxx' \\\n -H 'Content-Type: application/json' \\\n -H 'Idempotency-Key: order-confirmation/12345' \\\n -d '{\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Order Confirmation\",\n \"html\": \"\u003cp>Your order has been confirmed.\u003c/p>\"\n }'\n```\n\n## Error Handling\n\n### Common Error Codes\n\n| Code | Name | Description | Action |\n|------|------|-------------|--------|\n| 400 | `validation_error` | Invalid parameters | Fix request, don't retry |\n| 400 | `invalid_idempotency_key` | Key must be 1-256 characters | Fix key format, don't retry |\n| 401 | `authentication_error` | Invalid API key | Check RESEND_API_KEY, don't retry |\n| 403 | `authorization_error` | Domain not verified | Verify domain at resend.com/domains |\n| 409 | `invalid_idempotent_request` | Key used with different payload | Use new key or fix payload |\n| 409 | `concurrent_idempotent_requests` | Same key request in progress | Wait and retry |\n| 422 | `unprocessable_entity` | Invalid email format/content | Fix content, don't retry |\n| 429 | `rate_limit_exceeded` | Too many requests | Retry with exponential backoff |\n| 500 | `api_error` | Server error | Retry with exponential backoff |\n\n### Retryable vs Non-Retryable\n\n**Don't retry (fix the request):**\n- 400 - Bad request / validation errors\n- 401 - Invalid API key\n- 403 - Domain not verified\n- 409 - Idempotency conflict (different payload)\n- 422 - Unprocessable entity\n\n**Safe to retry with backoff:**\n- 429 - Rate limited\n- 500 - Server error\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.emails.send({\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Hello',\n html: '\u003cp>Hello world\u003c/p>',\n});\n\nif (error) {\n switch (error.name) {\n case 'validation_error':\n // Invalid parameters - don't retry, fix the data\n throw new Error(`Invalid email params: ${error.message}`);\n\n case 'rate_limit_exceeded':\n // Rate limited - safe to retry with backoff\n console.log('Rate limited, should retry with backoff');\n break;\n\n case 'api_error':\n // Server error - safe to retry\n console.log('Server error, should retry');\n break;\n\n case 'invalid_idempotent_request':\n // Idempotency conflict - don't retry with same key\n throw new Error('Duplicate request with different payload');\n\n default:\n console.error('Unexpected error:', error);\n }\n return;\n}\n\nconsole.log('Email sent:', data.id);\n```\n\n### Python\n\n```python\nimport resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\ntry:\n email = resend.Emails.send({\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Hello\",\n \"html\": \"\u003cp>Hello world\u003c/p>\",\n })\n print(f\"Email sent: {email['id']}\")\nexcept resend.exceptions.ValidationError as e:\n # Invalid parameters - don't retry\n print(f\"Validation error: {e}\")\nexcept resend.exceptions.RateLimitError as e:\n # Rate limited - retry after delay\n print(f\"Rate limited: {e}\")\nexcept resend.exceptions.ResendError as e:\n # Other API error\n print(f\"API error: {e}\")\n```\n\n### Go\n\n```go\nimport (\n \"fmt\"\n \"github.com/resend/resend-go/v4\"\n)\n\nclient := resend.NewClient(os.Getenv(\"RESEND_API_KEY\"))\n\nparams := &resend.SendEmailRequest{\n From: \"Acme \[email protected]>\",\n To: []string{\"[email protected]\"},\n Subject: \"Hello\",\n Html: \"\u003cp>Hello world\u003c/p>\",\n}\n\nsent, err := client.Emails.Send(params)\nif err != nil {\n // Check error type and handle accordingly\n fmt.Printf(\"Failed to send email: %v\\n\", err)\n return\n}\n\nfmt.Printf(\"Email sent: %s\\n\", sent.Id)\n```\n\n## Retry Logic\n\nImplement exponential backoff for transient failures. Don't retry validation errors or idempotency conflicts.\n\n### Strategy\n\n| Attempt | Delay | Total Wait |\n|---------|-------|------------|\n| 1 | 1s | 1s |\n| 2 | 2s | 3s |\n| 3 | 4s | 7s |\n| 4 | 8s | 15s |\n| 5 | 16s | 31s |\n\n**Recommendations:**\n- Max 3-5 retries for most use cases\n- Only retry 429 (rate limit) and 500 (server error)\n- Always use idempotency keys when retrying\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nasync function sendEmailWithRetry(\n params: Parameters\u003ctypeof resend.emails.send>[0],\n options: { maxRetries?: number; idempotencyKey?: string } = {}\n) {\n const { maxRetries = 3, idempotencyKey } = options;\n\n for (let attempt = 0; attempt \u003c= maxRetries; attempt++) {\n const { data, error } = await resend.emails.send(\n params,\n idempotencyKey ? { idempotencyKey } : undefined\n );\n\n if (!error) {\n return data;\n }\n\n // Don't retry validation errors or idempotency conflicts\n if (error.name === 'validation_error' || error.name === 'invalid_idempotent_request') {\n throw new Error(`${error.name}: ${error.message}`);\n }\n\n // Last attempt failed\n if (attempt === maxRetries) {\n throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);\n }\n\n // Exponential backoff: 1s, 2s, 4s...\n const delay = Math.pow(2, attempt) * 1000;\n console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n}\n\n// Usage\nconst result = await sendEmailWithRetry(\n {\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Order Confirmation',\n html: '\u003cp>Your order is confirmed.\u003c/p>',\n },\n { idempotencyKey: `order-confirmation/${orderId}` }\n);\n```\n\n### Python\n\n```python\nimport resend\nimport os\nimport time\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\ndef send_email_with_retry(params, max_retries=3, idempotency_key=None):\n for attempt in range(max_retries + 1):\n try:\n return resend.Emails.send(params, idempotency_key=idempotency_key)\n except resend.exceptions.ValidationError:\n # Don't retry validation errors\n raise\n except resend.exceptions.ResendError as e:\n if attempt == max_retries:\n raise Exception(f\"Failed after {max_retries + 1} attempts: {e}\")\n\n # Exponential backoff: 1s, 2s, 4s...\n delay = (2 ** attempt)\n print(f\"Attempt {attempt + 1} failed, retrying in {delay}s...\")\n time.sleep(delay)\n\n# Usage\nresult = send_email_with_retry(\n {\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Order Confirmation\",\n \"html\": \"\u003cp>Your order is confirmed.\u003c/p>\",\n },\n idempotency_key=f\"order-confirmation/{order_id}\"\n)\n```\n\n### Go\n\n```go\nimport (\n \"fmt\"\n \"time\"\n \"github.com/resend/resend-go/v4\"\n)\n\nfunc sendEmailWithRetry(client *resend.Client, params *resend.SendEmailRequest, maxRetries int) (*resend.SendEmailResponse, error) {\n var lastErr error\n\n for attempt := 0; attempt \u003c= maxRetries; attempt++ {\n sent, err := client.Emails.Send(params)\n if err == nil {\n return sent, nil\n }\n\n lastErr = err\n\n if attempt == maxRetries {\n break\n }\n\n // Exponential backoff: 1s, 2s, 4s...\n delay := time.Duration(1\u003c\u003cattempt) * time.Second\n fmt.Printf(\"Attempt %d failed, retrying in %v...\\n\", attempt+1, delay)\n time.Sleep(delay)\n }\n\n return nil, fmt.Errorf(\"failed after %d attempts: %w\", maxRetries+1, lastErr)\n}\n\n// Usage\nclient := resend.NewClient(os.Getenv(\"RESEND_API_KEY\"))\n\nparams := &resend.SendEmailRequest{\n From: \"Acme \[email protected]>\",\n To: []string{\"[email protected]\"},\n Subject: \"Order Confirmation\",\n Html: \"\u003cp>Your order is confirmed.\u003c/p>\",\n Headers: map[string]string{\n \"Idempotency-Key\": fmt.Sprintf(\"order-confirmation/%s\", orderID),\n },\n}\n\nsent, err := sendEmailWithRetry(client, params, 3)\n```\n\n## Batch-Specific Practices\n\n### Pre-send Validation\n\nThe entire batch fails if any single email has invalid data. Always validate before sending.\n\n**Key validations:**\n- Batch size: 1-100 emails\n- Recipients per email: 1-50\n- Required fields: `from`, `to`, `subject`, `html` or `text`\n- Valid email format for all recipients\n\nSee [batch-email-examples.md](batch-email-examples.md) for complete validation implementations.\n\n### Chunking Large Batches\n\nFor sends larger than 100 emails, chunk into multiple batch requests with unique idempotency keys per chunk.\n\n```typescript\n// Node.js example pattern\nconst BATCH_SIZE = 100;\n\nasync function sendLargeBatch(emails: Email[], batchPrefix: string) {\n const chunks: Email[][] = [];\n\n for (let i = 0; i \u003c emails.length; i += BATCH_SIZE) {\n chunks.push(emails.slice(i, i + BATCH_SIZE));\n }\n\n const results = await Promise.all(\n chunks.map(async (chunk, index) => {\n // Each chunk gets its own idempotency key\n const idempotencyKey = `${batchPrefix}/chunk-${index}`;\n return resend.batch.send(chunk, { idempotencyKey });\n })\n );\n\n return results;\n}\n```\n\nSee [batch-email-examples.md](batch-email-examples.md) for complete chunking implementations in all SDKs.\n\n### Batch Limitations\n\nRemember that the batch endpoint does **NOT** support:\n- `attachments` - Use individual sends for emails with attachments\n- `scheduled_at` - Use individual sends for scheduled emails\n- Partial success - If one email fails validation, the entire batch fails\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12661,"content_sha256":"a1b075637f86a649bb5810ad342c44b154164d8a18f2f1163fb1bb12d24a13d8"},{"filename":"references/sending/email-management.md","content":"# Email Management\n\n## Overview\n\nAfter sending, emails can be retrieved, listed, rescheduled, cancelled, or shared. Updates are limited to `scheduled_at` only — content cannot be changed after creation.\n\n## SDK Methods\n\n### Node.js\n\n| Operation | Method | Notes |\n|-----------|--------|-------|\n| Get | `resend.emails.get(id)` | Returns full email details and status |\n| List | `resend.emails.list({ limit, offset })` | Paginated list of sent emails |\n| Update | `resend.emails.update({ id, scheduledAt })` | Reschedule only — no content changes |\n| Cancel | `resend.emails.cancel(id)` | Cancel a scheduled email before it sends |\n| Share | `resend.emails.share(id, { expiresIn })` | Create a public link for a sent or received email; `expiresIn` defaults to and caps at 48h |\n\n### Python\n\n| Operation | Method |\n|-----------|--------|\n| Get | `resend.Emails.get(id)` |\n| List | `resend.Emails.list(params)` |\n| Update | `resend.Emails.update(params)` — params: `{ \"id\": ..., \"scheduled_at\": ... }` |\n| Cancel | `resend.Emails.cancel(id)` |\n| Share | `resend.Emails.share(email_id, params)` — params: `{ \"expires_in\": ... }` |\n\n## Examples\n\n### Get Email\n\n```typescript\n// Node.js — always destructure { data, error }\nconst { data, error } = await resend.emails.get('email_abc123');\nif (error) {\n console.error(error);\n return;\n}\nconsole.log(data.status); // 'delivered', 'bounced', 'scheduled', etc.\n```\n\n```python\n# Python — returns data directly\nemail = resend.Emails.get(\"email_abc123\")\nprint(email[\"status\"])\n```\n\n### Reschedule a Scheduled Email\n\n```typescript\nconst { data, error } = await resend.emails.update({\n id: 'email_abc123',\n scheduledAt: '2026-04-01T09:00:00Z',\n});\nif (error) console.error(error);\n```\n\n```python\nresend.Emails.update({\n \"id\": \"email_abc123\",\n \"scheduled_at\": \"2026-04-01T09:00:00Z\",\n})\n```\n\n### Cancel a Scheduled Email\n\n```typescript\nconst { data, error } = await resend.emails.cancel('email_abc123');\nif (error) console.error(error);\n```\n\n```python\nresend.Emails.cancel(\"email_abc123\")\n```\n\n### Share a Sent or Received Email\n\nCreates a public, unauthenticated link — anyone with the URL can view the email. Works for both sent and received emails; the API detects which type the ID belongs to.\n\n```typescript\nconst { data, error } = await resend.emails.share('email_abc123', {\n expiresIn: '2 hours', // optional — human-readable duration, defaults to and caps at 48h\n});\nif (error) {\n console.error(error);\n} else {\n console.log(data.url);\n}\n```\n\n```python\nshared = resend.Emails.share(\"email_abc123\", {\"expires_in\": \"2 hours\"})\nprint(shared[\"url\"])\n```\n\n## Retrieving Attachments\n\nList and download attachments for sent emails. Returns metadata and a signed download URL.\n\n### SDK Methods\n\n| Operation | Node.js | Python |\n|-----------|---------|--------|\n| List | `resend.emails.attachments.list({ emailId })` | `resend.Emails.Attachments.list(email_id)` |\n| Get | `resend.emails.attachments.get({ emailId, id })` | `resend.Emails.Attachments.get(email_id, attachment_id)` |\n\n### Examples\n\n```typescript\n// List all attachments for a sent email\nconst { data: attachments, error } = await resend.emails.attachments.list({\n emailId: 'email_abc123',\n});\n\nif (error) {\n console.error(error);\n return;\n}\n\nfor (const att of attachments.data) {\n console.log(att.filename); // 'invoice.pdf'\n console.log(att.content_type); // 'application/pdf'\n console.log(att.size); // bytes\n console.log(att.download_url); // signed URL, expires at att.expires_at\n}\n\n// Get a single attachment\nconst { data: attachment, error: getError } = await resend.emails.attachments.get({\n emailId: 'email_abc123',\n id: 'att_def456',\n});\n\nif (getError) {\n console.error(getError);\n return;\n}\n\n// Download the content\nconst response = await fetch(attachment.download_url);\nconst buffer = await response.arrayBuffer();\n```\n\n**Important:** `download_url` expires (see `expires_at` field). Call the API again for a fresh URL if needed.\n\n### Attachment Response Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | string | Attachment ID |\n| `filename` | string | Original filename |\n| `content_type` | string | MIME type |\n| `content_id` | string | Content ID for inline attachments |\n| `content_disposition` | `\"inline\"` \\| `\"attachment\"` | Display mode |\n| `download_url` | string | Signed download URL |\n| `expires_at` | string | When the download URL expires |\n| `size` | number | Size in bytes |\n\n## Retrieving Metrics\n\nAccount-level email delivery and engagement metrics (sent, delivered, bounced, opened, clicked, etc.) for a date range. With no options, returns totals only. Optionally broken down by one or more dimensions: `period`, `domain`, `email`, `broadcast`.\n\n`email` and `broadcast` are mutually exclusive — as dimensions, and as filters (`emailId`/`broadcastId`). Requesting both, in either form, is rejected.\n\n### SDK Methods\n\n| Operation | Node.js | Python |\n|-----------|---------|--------|\n| Get metrics | `resend.emails.metrics(options)` | `resend.Emails.metrics(params)` |\n\n`options`/`params` (all optional):\n\n| Field (Node.js / Python) | Type | Notes |\n|-------|------|-------|\n| `startDate` / `start_date` | string | ISO 8601 date or datetime. Defaults to 6 days before `endDate` |\n| `endDate` / `end_date` | string | ISO 8601 date or datetime. Defaults to now |\n| `timezone` | string | IANA timezone, e.g. `America/New_York`. Defaults to UTC |\n| `granularity` | string | `hourly`, `daily`, `weekly`, or `monthly` — bucket size when `period` is a dimension. Defaults to `daily` |\n| `metrics` | string[] | Which metrics to include. Defaults to all |\n| `dimensions` | string[] | `period`, `domain`, `email`, `broadcast` — combinable except `email`+`broadcast` |\n| `domainId` / `domain_id` | string[] | Restrict to these sending domain IDs (max 100) |\n| `emailId` / `email_id` | string[] | Restrict to these email IDs (max 100). Cannot combine with `broadcast` dimension/`broadcastId` |\n| `broadcastId` / `broadcast_id` | string[] | Restrict to these broadcast IDs (max 100). Cannot combine with `email` dimension/`emailId` |\n\n### Examples\n\n```typescript\n// Totals only, default 6-day window\nconst { data, error } = await resend.emails.metrics();\nif (error) {\n console.error(error);\n return;\n}\nconsole.log(data.totals.sent, data.totals.delivered);\n```\n\n```python\n# Totals only, default 6-day window\nmetrics = resend.Emails.metrics({})\nprint(metrics[\"totals\"][\"sent\"], metrics[\"totals\"][\"delivered\"])\n```\n\n```typescript\n// Broken down by period and broadcast, filtered to one broadcast\nconst { data, error } = await resend.emails.metrics({\n startDate: '2026-07-01',\n endDate: '2026-07-08',\n dimensions: ['period', 'broadcast'],\n broadcastId: ['bc_abc123'],\n});\nif (error) console.error(error);\n\nfor (const row of data.data ?? []) {\n console.log(row.period, row.broadcast_name, row.delivered);\n}\n```\n\n```python\nmetrics = resend.Emails.metrics({\n \"start_date\": \"2026-07-01\",\n \"end_date\": \"2026-07-08\",\n \"dimensions\": [\"period\", \"broadcast\"],\n \"broadcast_id\": [\"bc_abc123\"],\n})\n\nfor row in metrics.get(\"data\", []):\n print(row[\"period\"], row[\"broadcast_name\"], row[\"delivered\"])\n```\n\n### Metrics Response Fields\n\n| Field | Type | Description |\n|-------|------|--------------|\n| `totals` | object | Metric totals for the whole date range, keyed by metric name |\n| `data` | array \\| absent | Per-dimension breakdown rows. Absent when no `dimensions` were requested |\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Trying to update `subject`, `html`, or `to` | Only `scheduledAt` can be updated — cancel and resend for content changes |\n| Cancelling an already-sent email | Cancel only works on emails with `scheduled` status |\n| Cancelling too late | Cancel before the `scheduled_at` time — there's a brief processing window before send |\n| Not checking `error` in Node.js | SDK returns `{ data, error }`, does not throw — always destructure and check |\n| Using `.list()` without pagination | Pass `limit` and `offset` to paginate through results |\n| Combining `email` and `broadcast` in metrics | These are mutually exclusive as dimensions and as filters — the request is rejected |\n| Expecting `unique_opened`/`open_rate`-style metrics without tracking enabled | Open/click tracking must be enabled on the sending domain for these to be meaningful |\n| Assuming a longer `expiresIn` is possible | 48 hours is the maximum — requesting more returns a validation error |\n| Treating share links as revocable | There's no revoke endpoint — the link is valid until it expires, no early invalidation |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8652,"content_sha256":"16d649da84356fba3cbcc5136c4c5154208ce39b4589843af5c77e8e2bbf9d66"},{"filename":"references/sending/overview.md","content":"# Sending Emails with Resend\n\n## Overview\n\nResend provides two endpoints for sending emails:\n\n| Approach | Endpoint | Use Case |\n|----------|----------|----------|\n| **Single** | `POST /emails` | Individual transactional emails, emails with attachments, scheduled sends |\n| **Batch** | `POST /emails/batch` | Multiple distinct emails in one request (max 100), bulk notifications |\n\n**Choose batch when:**\n- Sending 2+ distinct emails at once\n- Reducing API calls is important (by default, rate limit is 2 requests per second)\n- No attachments or scheduling needed\n\n**Choose single when:**\n- Sending one email\n- Email needs attachments\n- Email needs to be scheduled\n- Different recipients need different timing\n\n## Quick Start\n\n1. **Detect project language** from config files (package.json, requirements.txt, go.mod, etc.)\n2. **Install SDK** (preferred) or use cURL — See [../installation.md](../installation.md)\n3. **Choose single or batch** based on the decision matrix above\n4. **Implement best practices** — Idempotency keys, error handling, retries. See [best-practices.md](best-practices.md)\n\n## Single Email\n\n**Endpoint:** `POST /emails` (prefer SDK over cURL)\n\n### Required Parameters\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `from` | string | Sender address. Format: `\"Name \[email protected]>\"` |\n| `to` | string[] | Recipient addresses (max 50) |\n| `subject` | string | Email subject line |\n| `html` or `text` | string | Email body content |\n\n### Optional Parameters\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `cc` | string[] | CC recipients |\n| `bcc` | string[] | BCC recipients |\n| `reply_to`* | string[] | Reply-to addresses |\n| `scheduled_at`* | string | Schedule send time (ISO 8601) |\n| `attachments` | array | File attachments (max 40MB total) |\n| `tags` | array | Key/value pairs for tracking (see [Tags](#tags)) |\n| `headers` | object | Custom headers |\n| `topic_id`* | string | Scope email to a topic — if the recipient contact has opted out of this topic, the email is silently skipped |\n\n*Parameter naming varies by SDK (e.g., `replyTo` in Node.js, `reply_to` in Python).\n\nSee [single-email-examples.md](single-email-examples.md) for full SDK implementations with error handling and retry logic.\n\n## Batch Email\n\n**Endpoint:** `POST /emails/batch` (prefer SDK over cURL)\n\n### Limitations\n\n- **No attachments** — Use single sends for emails with attachments\n- **No scheduling** — Use single sends for scheduled emails\n- **Atomic** — If one email fails validation, the entire batch fails\n- **Max 100 emails** per request\n- **Max 50 recipients** per individual email in the batch\n\n### Pre-validation\n\nSince the entire batch fails on any validation error, validate all emails before sending:\n- Check required fields (from, to, subject, html/text)\n- Validate email formats\n- Ensure batch size \u003c= 100\n\nSee [batch-email-examples.md](batch-email-examples.md) for full SDK implementations with validation, chunking, and retry logic.\n\n## Large Batches (100+ Emails)\n\nFor sends larger than 100 emails, chunk into multiple batch requests:\n\n1. **Split into chunks** of 100 emails each\n2. **Use unique idempotency keys** per chunk: `\u003cbatch-prefix>/chunk-\u003cindex>`\n3. **Send chunks in parallel** for better throughput\n4. **Track results** per chunk to handle partial failures\n\nSee [batch-email-examples.md](batch-email-examples.md) for complete chunking implementations.\n\n## Deliverability\n\nFollow these practices to maximize inbox placement.\n\nFor more help with deliverability, install the email-best-practices skill with `npx skills add resend/email-best-practices`.\n\n### Required\n\n| Practice | Why |\n|----------|-----|\n| **Valid SPF, DKIM, DMARC record** | Authenticate the email and prevent spoofing |\n| **Links match sending domain** | If sending from `@acme.com`, link to `https://acme.com` — mismatched domains trigger spam filters |\n| **Include plain text version** | Use both `html` and `text` parameters for accessibility and deliverability |\n| **Avoid \"no-reply\" addresses** | Use real addresses (e.g., `support@`) — improves trust signals |\n| **Keep body under 102KB** | Gmail clips larger messages |\n\n### Recommended\n\n| Practice | Why |\n|----------|-----|\n| **Use subdomains** | Send transactional from `notifications.acme.com`, marketing from `mail.acme.com` — protects reputation |\n| **Disable tracking for transactional** | Open/click tracking can trigger spam filters for password resets, receipts, etc. |\n\n## Tracking (Opens & Clicks)\n\nTracking is configured at the **domain level** in the Resend dashboard, not per-email.\n\n| Setting | How it works | Recommendation |\n|---------|--------------|----------------|\n| **Open tracking** | Inserts 1x1 transparent pixel | Disable for transactional emails |\n| **Click tracking** | Rewrites links through redirect | Disable for sensitive emails |\n\nConfigure via dashboard: Domain → Configuration → Click/Open Tracking.\n\nTo track different email types separately (e.g., tracking on for marketing, off for transactional), use **separate subdomains**.\n\n## Tags\n\nTags are key/value pairs that help you track and filter emails.\n\n```typescript\ntags: [\n { name: 'user_id', value: 'usr_123' },\n { name: 'email_type', value: 'welcome' },\n]\n```\n\n**Constraints:** Tag names and values can only contain ASCII letters, numbers, underscores, or dashes. Max 256 characters each.\n\n## Templates\n\nUse pre-built templates instead of sending HTML with each request:\n\n```typescript\nconst { data, error } = await resend.emails.send({\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Welcome!',\n template: {\n id: 'tmpl_abc123', // or alias: 'welcome-email'\n variables: {\n USER_NAME: 'John', // Case-sensitive! Must match template exactly.\n }\n }\n});\n```\n\nCannot combine `template` with `html`, `text`, or `react` — mutually exclusive. See [../templates.md](../templates.md) for full template management.\n\n## Testing\n\n**Avoid testing with fake addresses at real email providers** — they bounce and destroy sender reputation.\n\n| Method | Address | Result |\n|--------|---------|--------|\n| **Delivered** | `[email protected]` | Simulates successful delivery |\n| **Bounced** | `[email protected]` | Simulates hard bounce |\n| **Complained** | `[email protected]` | Simulates spam complaint |\n| **Your own email** | Your actual address | Real delivery test |\n\n## Domain Warm-up\n\nNew domains must gradually increase sending volume to establish reputation.\n\n**New domain schedule:**\n\n| Day | Messages per day |\n|-----|-----------------|\n| 1 | Up to 150 |\n| 2 | Up to 250 |\n| 3 | Up to 400 |\n| 4 | Up to 700 |\n| 5 | Up to 1,000 |\n| 6 | Up to 1,500 |\n| 7 | Up to 2,000 |\n\n**Existing domain schedule:**\n\n| Day | Messages per day |\n|-----|-----------------|\n| 1 | Up to 1,000 |\n| 2 | Up to 2,500 |\n| 3–4 | Up to 5,000 |\n| 5–6 | Up to 7,500 |\n| 7 | Up to 10,000 |\n\nMonitor: bounce rate \u003c 4%, spam complaint rate \u003c 0.08%.\n\n## Suppression List\n\nResend automatically manages a suppression list. Addresses are added when emails hard bounce or recipients mark as spam. Resend won't attempt delivery to suppressed addresses — the `email.suppressed` webhook event fires instead. Manage in Dashboard → Suppressions.\n\n## Notes\n\n- The `from` address must use a verified domain\n- If the sending address cannot receive replies, set the `reply_to` parameter\n- Node.js SDK supports `react` parameter for React Email components\n- Resend returns `{ error, data }` — data is `{ id: \"email-id\" }` on success (single) or array of IDs (batch)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7614,"content_sha256":"2191c5008d1456f7a076e62419bed93c45a5f48bf3ac23d746a19264d1dd5a5b"},{"filename":"references/sending/single-email-examples.md","content":"# Resend Examples\n\n## Table of Contents\n\n- [Idempotency Keys](#idempotency-keys)\n- [Error Handling](#error-handling)\n- [Retry Logic](#retry-logic)\n- [Complete Examples](#complete-examples)\n\n## Idempotency Keys\n\nUse idempotency keys to prevent duplicate emails when retrying failed requests. Keys expire after 24 hours and have a max length of 256 characters.\n\n**Key format:** Use `\u003cevent-type>/\u003centity-id>` pattern (e.g., `welcome-email/user-123`, `order-confirmation/order-456`).\n\n**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.\n\n### Node.js\n\nThe Node.js SDK has a dedicated `idempotencyKey` option:\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.emails.send(\n {\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Order Confirmation',\n html: '\u003cp>Your order has been confirmed.\u003c/p>',\n },\n {\n idempotencyKey: `order-confirmation/${orderId}`,\n }\n);\n```\n\n### Python\n\n```python\nimport resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\nemail = resend.Emails.send({\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Order Confirmation\",\n \"html\": \"\u003cp>Your order has been confirmed.\u003c/p>\",\n}, idempotency_key=f\"order-confirmation/{order_id}\")\n```\n\n### Go\n\nOther SDKs use the `Idempotency-Key` header:\n\n```go\nimport \"github.com/resend/resend-go/v4\"\n\nclient := resend.NewClient(os.Getenv(\"RESEND_API_KEY\"))\n\nparams := &resend.SendEmailRequest{\n From: \"Acme \[email protected]>\",\n To: []string{\"[email protected]\"},\n Subject: \"Order Confirmation\",\n Html: \"\u003cp>Your order has been confirmed.\u003c/p>\",\n Headers: map[string]string{\n \"Idempotency-Key\": fmt.Sprintf(\"order-confirmation/%s\", orderID),\n },\n}\n\nsent, err := client.Emails.Send(params)\n```\n\n### cURL\n\n```bash\ncurl -X POST 'https://api.resend.com/emails' \\\n -H 'Authorization: Bearer re_xxxxxxxxx' \\\n -H 'Content-Type: application/json' \\\n -H 'Idempotency-Key: order-confirmation/12345' \\\n -d '{\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Order Confirmation\",\n \"html\": \"\u003cp>Your order has been confirmed.\u003c/p>\"\n }'\n```\n\n## Error Handling\n\n### Common Error Codes\n\n| Code | Description | Action |\n|------|-------------|--------|\n| 400 | Bad request (invalid params) | Fix request parameters |\n| 400 | `invalid_idempotency_key` | Key must be 1-256 characters |\n| 401 | Invalid API key | Check RESEND_API_KEY |\n| 403 | Domain not verified | Verify domain at resend.com/domains |\n| 409 | `invalid_idempotent_request` | Key already used with different payload |\n| 409 | `concurrent_idempotent_requests` | Same key request in progress, retry later |\n| 422 | Unprocessable entity | Check email format/content |\n| 429 | Rate limited | Implement backoff and retry |\n| 500 | Server error | Retry with backoff |\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.emails.send({\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Hello',\n html: '\u003cp>Hello world\u003c/p>',\n});\n\nif (error) {\n console.error('Failed to send email:', error.message);\n\n // Handle specific error types\n if (error.name === 'validation_error') {\n // Invalid parameters - don't retry\n throw new Error(`Invalid email params: ${error.message}`);\n }\n\n if (error.name === 'rate_limit_exceeded') {\n // Rate limited - retry after delay\n console.log('Rate limited, retrying...');\n }\n\n return;\n}\n\nconsole.log('Email sent:', data.id);\n```\n\n### Python\n\n```python\nimport resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\ntry:\n email = resend.Emails.send({\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Hello\",\n \"html\": \"\u003cp>Hello world\u003c/p>\",\n })\n print(f\"Email sent: {email['id']}\")\nexcept resend.exceptions.ValidationError as e:\n # Invalid parameters - don't retry\n print(f\"Validation error: {e}\")\nexcept resend.exceptions.RateLimitError as e:\n # Rate limited - retry after delay\n print(f\"Rate limited: {e}\")\nexcept resend.exceptions.ResendError as e:\n # Other API error\n print(f\"API error: {e}\")\n```\n\n### Go\n\n```go\nimport (\n \"fmt\"\n \"github.com/resend/resend-go/v4\"\n)\n\nclient := resend.NewClient(os.Getenv(\"RESEND_API_KEY\"))\n\nparams := &resend.SendEmailRequest{\n From: \"Acme \[email protected]>\",\n To: []string{\"[email protected]\"},\n Subject: \"Hello\",\n Html: \"\u003cp>Hello world\u003c/p>\",\n}\n\nsent, err := client.Emails.Send(params)\nif err != nil {\n // Check error type and handle accordingly\n fmt.Printf(\"Failed to send email: %v\\n\", err)\n return\n}\n\nfmt.Printf(\"Email sent: %s\\n\", sent.Id)\n```\n\n## Retry Logic\n\nImplement exponential backoff for transient failures (rate limits, server errors). Don't retry 409 `invalid_idempotent_request` errors (different payload).\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nasync function sendEmailWithRetry(\n params: Parameters\u003ctypeof resend.emails.send>[0],\n options: { maxRetries?: number; idempotencyKey?: string } = {}\n) {\n const { maxRetries = 3, idempotencyKey } = options;\n\n for (let attempt = 0; attempt \u003c= maxRetries; attempt++) {\n const { data, error } = await resend.emails.send(\n params,\n idempotencyKey ? { idempotencyKey } : undefined\n );\n\n if (!error) {\n return data;\n }\n\n // Don't retry validation errors or idempotency conflicts\n if (error.name === 'validation_error' || error.name === 'invalid_idempotent_request') {\n throw new Error(`${error.name}: ${error.message}`);\n }\n\n // Last attempt failed\n if (attempt === maxRetries) {\n throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);\n }\n\n // Exponential backoff: 1s, 2s, 4s...\n const delay = Math.pow(2, attempt) * 1000;\n console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n}\n\n// Usage\nconst result = await sendEmailWithRetry(\n {\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Order Confirmation',\n html: '\u003cp>Your order is confirmed.\u003c/p>',\n },\n { idempotencyKey: `order-confirmation/${orderId}` }\n);\n```\n\n### Python\n\n```python\nimport resend\nimport os\nimport time\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\ndef send_email_with_retry(params, max_retries=3, idempotency_key=None):\n for attempt in range(max_retries + 1):\n try:\n return resend.Emails.send(params, idempotency_key=idempotency_key)\n except resend.exceptions.ValidationError:\n # Don't retry validation errors\n raise\n except resend.exceptions.ResendError as e:\n if attempt == max_retries:\n raise Exception(f\"Failed after {max_retries + 1} attempts: {e}\")\n\n # Exponential backoff: 1s, 2s, 4s...\n delay = (2 ** attempt)\n print(f\"Attempt {attempt + 1} failed, retrying in {delay}s...\")\n time.sleep(delay)\n\n# Usage\nresult = send_email_with_retry(\n {\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Order Confirmation\",\n \"html\": \"\u003cp>Your order is confirmed.\u003c/p>\",\n },\n idempotency_key=f\"order-confirmation/{order_id}\"\n)\n```\n\n### Go\n\n```go\nimport (\n \"fmt\"\n \"time\"\n \"github.com/resend/resend-go/v4\"\n)\n\nfunc sendEmailWithRetry(client *resend.Client, params *resend.SendEmailRequest, maxRetries int) (*resend.SendEmailResponse, error) {\n var lastErr error\n\n for attempt := 0; attempt \u003c= maxRetries; attempt++ {\n sent, err := client.Emails.Send(params)\n if err == nil {\n return sent, nil\n }\n\n lastErr = err\n\n if attempt == maxRetries {\n break\n }\n\n // Exponential backoff: 1s, 2s, 4s...\n delay := time.Duration(1\u003c\u003cattempt) * time.Second\n fmt.Printf(\"Attempt %d failed, retrying in %v...\\n\", attempt+1, delay)\n time.Sleep(delay)\n }\n\n return nil, fmt.Errorf(\"failed after %d attempts: %w\", maxRetries+1, lastErr)\n}\n\n// Usage\nclient := resend.NewClient(os.Getenv(\"RESEND_API_KEY\"))\n\nparams := &resend.SendEmailRequest{\n From: \"Acme \[email protected]>\",\n To: []string{\"[email protected]\"},\n Subject: \"Order Confirmation\",\n Html: \"\u003cp>Your order is confirmed.\u003c/p>\",\n Headers: map[string]string{\n \"Idempotency-Key\": fmt.Sprintf(\"order-confirmation/%s\", orderID),\n },\n}\n\nsent, err := sendEmailWithRetry(client, params, 3)\n```\n\n## Complete Examples\n\n### Node.js - Production-Ready Email Service\n\n```typescript\nimport { Resend } from 'resend';\nimport { randomUUID } from 'crypto';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\ninterface SendEmailOptions {\n to: string | string[];\n subject: string;\n html: string;\n from?: string;\n replyTo?: string;\n idempotencyKey?: string;\n maxRetries?: number;\n}\n\nasync function sendEmail(options: SendEmailOptions) {\n const {\n to,\n subject,\n html,\n from = 'Acme \[email protected]>',\n replyTo,\n idempotencyKey = randomUUID(),\n maxRetries = 3,\n } = options;\n\n for (let attempt = 0; attempt \u003c= maxRetries; attempt++) {\n const { data, error } = await resend.emails.send(\n {\n from,\n to: Array.isArray(to) ? to : [to],\n subject,\n html,\n ...(replyTo && { replyTo }),\n },\n { idempotencyKey }\n );\n\n if (!error) {\n return { success: true, id: data.id };\n }\n\n // Don't retry client errors or idempotency conflicts\n if (error.name === 'validation_error' || error.name === 'not_found' || error.name === 'invalid_idempotent_request') {\n return { success: false, error: error.message, retryable: false };\n }\n\n if (attempt \u003c maxRetries) {\n const delay = Math.pow(2, attempt) * 1000;\n await new Promise(r => setTimeout(r, delay));\n }\n }\n\n return { success: false, error: 'Max retries exceeded', retryable: true };\n}\n\n// Usage\nconst result = await sendEmail({\n to: '[email protected]',\n subject: 'Welcome!',\n html: '\u003ch1>Welcome to Acme\u003c/h1>',\n idempotencyKey: `welcome-email/${userId}`,\n});\n```\n\n### Python - Production-Ready Email Service\n\n```python\nimport resend\nimport os\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Optional, Union, List\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\n@dataclass\nclass EmailResult:\n success: bool\n id: Optional[str] = None\n error: Optional[str] = None\n retryable: bool = False\n\ndef send_email(\n to: Union[str, List[str]],\n subject: str,\n html: str,\n from_addr: str = \"Acme \[email protected]>\",\n reply_to: Optional[str] = None,\n idempotency_key: Optional[str] = None,\n max_retries: int = 3,\n) -> EmailResult:\n idempotency_key = idempotency_key or str(uuid.uuid4())\n recipients = [to] if isinstance(to, str) else to\n\n params = {\n \"from\": from_addr,\n \"to\": recipients,\n \"subject\": subject,\n \"html\": html,\n }\n if reply_to:\n params[\"reply_to\"] = reply_to\n\n for attempt in range(max_retries + 1):\n try:\n result = resend.Emails.send(params, idempotency_key=idempotency_key)\n return EmailResult(success=True, id=result[\"id\"])\n except resend.exceptions.ValidationError as e:\n return EmailResult(success=False, error=str(e), retryable=False)\n except resend.exceptions.ResendError as e:\n if attempt \u003c max_retries:\n time.sleep(2 ** attempt)\n else:\n return EmailResult(success=False, error=str(e), retryable=True)\n\n return EmailResult(success=False, error=\"Max retries exceeded\", retryable=True)\n\n# Usage\nresult = send_email(\n to=\"[email protected]\",\n subject=\"Welcome!\",\n html=\"\u003ch1>Welcome to Acme\u003c/h1>\",\n idempotency_key=f\"welcome-email/{user_id}\",\n)\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12319,"content_sha256":"9ff0df770d82487596f179e5781c723eb8691bf7f61917f46e090bc3c73834da"},{"filename":"references/templates.md","content":"# Resend Templates\n\n## Overview\n\nTemplates are reusable email structures stored on Resend. Define HTML and variables once; reference the template ID or alias when sending.\n\n**Use templates when:** the same structure is reused across many sends, non-engineers need to edit copy without touching code, or you want version history.\n\n**Use inline `html` when:** the structure changes per send, you need more than 50 dynamic variables, or you want tighter rendering control.\n\n## Template Lifecycle\n\n```\nCreate (draft) → Publish → Send\n ↑ edit |\n └─────────────────────┘\n```\n\nEditing a published template creates a new draft — the published version keeps sending until you publish again.\n\n| State | Can send? |\n|-------|-----------|\n| **Draft** | No |\n| **Published** | Yes |\n\n## Variable Syntax\n\nUse **triple mustache** in HTML and subject: `{{{VARIABLE_NAME}}}`\n\n```html\n\u003c!-- ✅ Correct -->\n\u003cp>Hi {{{CUSTOMER_NAME}}}, your order #{{{ORDER_ID}}} has shipped!\u003c/p>\n\n\u003c!-- ❌ Wrong — double braces don't render in Resend -->\n\u003cp>Hi {{CUSTOMER_NAME}}\u003c/p>\n```\n\nPlain substitution only — no `{{#each}}`, `{{#if}}`, or other Handlebars control flow. Pre-render dynamic lists server-side into a single HTML variable.\n\nVariable key casing is arbitrary (`ORDER_ID`, `orderId`, `order_id` all work) but must be consistent: whatever casing you use in the template definition must match exactly in the send call.\n\n### Variable Definition\n\n```typescript\n{ key: 'ORDER_TOTAL', type: 'number', fallbackValue: 0 }\n{ key: 'CUSTOMER_NAME', type: 'string', fallbackValue: 'Customer' }\n{ key: 'ORDER_ID', type: 'string' } // no fallbackValue = required at send time\n```\n\n- Variable **with** `fallbackValue` and missing at send → uses fallback, send succeeds\n- Variable **without** `fallbackValue` and missing at send → send **fails** (422)\n\n### Variable Constraints\n\n| Constraint | Limit |\n|------------|-------|\n| Max variables per template | 50 |\n| Key characters | ASCII letters, numbers, underscores only |\n| Key max length | 50 characters |\n| String value max | 2,000 characters |\n| Number value max | 2^53 − 1 |\n\n### Reserved Names\n\n`FIRST_NAME` · `LAST_NAME` · `EMAIL` · `UNSUBSCRIBE_URL` · `RESEND_UNSUBSCRIBE_URL` · `contact` · `this`\n\nThese cannot be used as custom variable keys — rename to `USER_FIRST_NAME`, `USER_EMAIL`, etc.\n\n## Sending with a Template\n\n```typescript\nconst { data, error } = await resend.emails.send(\n {\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n template: {\n id: 'order-confirmation', // alias or auto-generated ID\n variables: { CUSTOMER_NAME: 'Alice', ORDER_ID: '12345' },\n },\n },\n { idempotencyKey: `order-confirm/${orderId}` }\n);\n```\n\nCannot combine `template` with `html`, `text`, or `react` — mutually exclusive. `subject` and `from` from the template can be overridden per-send.\n\n## Aliases\n\nAn alias is a stable, human-readable slug you set at create time. Pass it in the `id` field anywhere you'd use the auto-generated template ID.\n\n```typescript\n// Set alias at create time\nawait resend.templates.create({\n name: 'Order Confirmation', // display-only\n alias: 'order-confirmation', // referenceable slug\n html: '\u003cp>Hi {{{CUSTOMER_NAME}}}\u003c/p>',\n});\n\n// Reference by alias — no need to store the generated tmpl_ ID\ntemplate: { id: 'order-confirmation', variables: { CUSTOMER_NAME: 'Alice' } }\n```\n\n## SDK Methods (Node.js)\n\n| Operation | Method |\n|-----------|--------|\n| Create | `resend.templates.create(params)` |\n| Get | `resend.templates.get(id)` |\n| List | `resend.templates.list(params)` |\n| Update | `resend.templates.update(id, params)` |\n| Delete | `resend.templates.remove(id)` ← not `.delete()` |\n| Publish | `resend.templates.publish(id)` |\n| Duplicate | `resend.templates.duplicate(id)` |\n\n### Create Template\n\n**Required:** `name`, `html` (or `react`)\n**Optional:** `alias`, `from`, `subject`, `reply_to`, `text`, `react`, `variables`\n\n```typescript\nconst { data, error } = await resend.templates.create({\n name: 'Order Confirmation',\n alias: 'order-confirmation',\n subject: 'Your Order #{{{ORDER_ID}}}',\n html: '\u003cp>Hi {{{CUSTOMER_NAME}}}, your order #{{{ORDER_ID}}} has shipped!\u003c/p>',\n variables: [\n { key: 'CUSTOMER_NAME', type: 'string', fallbackValue: 'Customer' },\n { key: 'ORDER_ID', type: 'string' },\n ],\n});\n// Returns: { id: 'tmpl_abc123', object: 'template' }\n```\n\n### Chainable Create → Publish\n\n```typescript\nconst { data, error } = await resend.templates.create({\n name: 'Welcome',\n html: '\u003cp>Hi {{{NAME}}}\u003c/p>',\n}).publish();\n// Template is created AND published in one call\n```\n\n### Get, List, Update, Delete\n\n```typescript\n// Get by ID or alias\nawait resend.templates.get('tmpl_abc123');\nawait resend.templates.get('order-confirmation'); // by alias\n\n// List — cursor-based pagination, max 100 per page\nconst { data } = await resend.templates.list({ limit: 100 });\n// data.has_more === true → fetch next page\nawait resend.templates.list({ limit: 100, after: data.data[data.data.length - 1].id });\n\n// Update (partial — only provided fields change)\nawait resend.templates.update('tmpl_abc123', { name: 'Order Confirmed' });\n\n// Delete\nawait resend.templates.remove('tmpl_abc123'); // returns { deleted: true }\n```\n\nSee `fetch-all-templates.mjs` for a complete pagination loop.\n\n### Publish\n\n```typescript\nawait resend.templates.publish('tmpl_abc123');\n// Template is now live. Publishing is synchronous — no delay needed before sending.\n```\n\n### Duplicate\n\n```typescript\nconst { data, error } = await resend.templates.duplicate('tmpl_abc123');\n// Returns: { id: 'tmpl_new456', object: 'template' }\n\n// Chainable — duplicate and publish in one call\nconst { data, error } = await resend.templates.duplicate('tmpl_abc123').publish();\n```\n\nUseful for creating variants (e.g., A/B testing subject lines) or bootstrapping new templates from an existing one.\n\n## Version History\n\nEvery template maintains full version history. Reverting creates a new draft from a previous version without affecting the published version. Accessible via the Resend dashboard template editor.\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| `{{VAR}}` instead of `{{{VAR}}}` | Triple braces required — double braces don't render variables |\n| Sending with draft template | Call `.publish()` first — draft templates cannot send |\n| Adding delay after create/publish | Publishing is synchronous — send failure has another cause |\n| `{{#each}}` or `{{#if}}` in template HTML | No loop/conditional support — pre-render dynamic lists server-side |\n| `html` + `template` in same send call | Mutually exclusive — remove `html` when using template |\n| Using `FIRST_NAME`, `EMAIL` as variable keys | Reserved — rename to `USER_FIRST_NAME`, `USER_EMAIL` |\n| Variable without fallback missing at send | Add `fallbackValue` or always provide the variable |\n| Calling `.delete()` | SDK method is `.remove()` |\n| Expecting alias = name | `alias` is a separate referenceable slug; `name` is display-only |\n| 60+ variables | Max 50 — pre-render complex content as a single HTML variable |\n| No idempotency key on sends | Template sends use the same endpoint — pass `idempotencyKey` |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7300,"content_sha256":"dfea43049abcf2ef19d0b8b3d95dc892ad65c2b4a09852035f0b7e9153935e29"},{"filename":"references/topics.md","content":"# Topics\n\nFine-grained subscription preferences — contacts opt in or out per topic. Topics control which contacts receive broadcasts when `topicId` is set.\n\n## SDK Methods\n\n### Node.js\n\n| Operation | Method |\n|-----------|--------|\n| Create | `resend.topics.create(params)` |\n| Get | `resend.topics.get(id)` |\n| List | `resend.topics.list()` — no pagination params |\n| Update | `resend.topics.update(params)` |\n| Delete | `resend.topics.remove(id)` — not `.delete()` |\n\n### Python\n\n| Operation | Method |\n|-----------|--------|\n| Create | `resend.Topics.create(params)` |\n| Get | `resend.Topics.get(id)` |\n| List | `resend.Topics.list(params?)` — `limit`, `after`, `before` |\n| Update | `resend.Topics.update(id, params)` |\n| Delete | `resend.Topics.remove(id)` |\n\n## Create Topic\n\n```typescript\nconst { data, error } = await resend.topics.create({\n name: 'Product Updates',\n defaultSubscription: 'opt_in', // REQUIRED: \"opt_in\" or \"opt_out\"\n description: 'New features and releases',\n});\n\nif (error) {\n console.error(error);\n return;\n}\n\nconsole.log(data.id); // topic_xxxxxxxx\n```\n\n## Update Topic\n\n`defaultSubscription` is **immutable** after creation. Only `name`, `description`, and `visibility` can be updated.\n\n```typescript\nconst { data, error } = await resend.topics.update({\n id: 'topic_xxx',\n name: 'Product News',\n description: 'News about our products',\n});\n\nif (error) {\n console.error(error);\n return;\n}\n```\n\n## Managing Contact Subscriptions\n\nUpdate a contact's topic subscriptions via the contacts sub-resource:\n\n```typescript\nawait resend.contacts.topics.update({\n id: 'cont_xxx',\n topics: [\n { id: 'topic_xxx', subscription: 'opt_in' },\n { id: 'topic_yyy', subscription: 'opt_out' },\n ],\n});\n```\n\n## Using Topics with Broadcasts\n\nPass `topicId` when creating a broadcast — only contacts subscribed to that topic receive it:\n\n```typescript\nawait resend.broadcasts.create({\n name: 'Feature Launch',\n segmentId: 'seg_xxx',\n topicId: 'topic_xxx',\n from: '[email protected]',\n subject: 'New Feature Launch',\n html: '\u003cp>Check it out!\u003c/p>',\n});\n```\n\n## Constraints\n\n| Constraint | Limit |\n|------------|-------|\n| Name max length | 50 characters |\n| Description max length | 200 characters |\n| `defaultSubscription` | `\"opt_in\"` or `\"opt_out\"` — immutable after create |\n| `visibility` | `\"public\"` (shown on the unsubscribe page) or `\"private\"` (default) |\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Omitting `defaultSubscription` on create | Required — must be `\"opt_in\"` or `\"opt_out\"` |\n| Trying to change `defaultSubscription` | Immutable after creation — delete and recreate with new value |\n| Calling `.delete()` | SDK method is `.remove()` |\n| `visibility: \"hidden\"` | Not a valid value — use `\"private\"` |\n| Broadcast without `topicId` | Goes to all contacts in segment regardless of topic preferences |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2883,"content_sha256":"4258030d53aa883c002b8c143899aaafe43ecb8865835000f411b959938e9eae"},{"filename":"references/usage.md","content":"# Usage\n\nAccount-level usage and quotas. A single snapshot endpoint — no ID, no pagination, no filters. Use it to check how close an account is to its plan limits for emails, contacts, segments, broadcasts, AI credits, automation runs, and domains, plus the account's API rate limit.\n\n## SDK Methods\n\n| Operation | Node.js | Python |\n|-----------|---------|--------|\n| Get | `resend.usage.get()` | `resend.Usage.get()` |\n\nMethod naming per SDK: Ruby `Resend::Usage.get`, Go `client.Usage.Get()`, PHP `$resend->usage->get()`, Java `resend.usage().get()`, .NET `resend.UsageAsync()`, Rust `resend.usage.get().await`.\n\n## Get Usage\n\n`GET /usage`\n\nTakes no parameters.\n\n### Node.js\n\n```typescript\nimport { Resend } from 'resend';\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.usage.get();\n\nif (error) {\n console.error(error);\n return;\n}\n\nconsole.log(`${data.emails.daily.used} emails today`);\nconsole.log(`${data.contacts.used} / ${data.contacts.limit} contacts`);\n```\n\n### Python\n\n```python\nimport resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\nusage = resend.Usage.get()\n\nprint(f'{usage[\"emails\"][\"daily\"][\"used\"]} emails today')\nprint(f'{usage[\"contacts\"][\"used\"]} / {usage[\"contacts\"][\"limit\"]} contacts')\n```\n\n### cURL\n\n```bash\ncurl -s \"https://api.resend.com/usage\" \\\n -H \"Authorization: Bearer $RESEND_API_KEY\" \\\n -H \"User-Agent: curl\"\n```\n\n### Response\n\n```json\n{\n \"object\": \"usage\",\n \"emails\": {\n \"daily\": { \"used\": 258, \"limit\": null, \"sent\": 57, \"received\": 201, \"resets_at\": \"2026-07-17T00:00:00.000Z\" },\n \"monthly\": { \"used\": 5442, \"limit\": 10000, \"sent\": 1000, \"received\": 4442, \"resets_at\": \"2026-08-01T00:00:00.000Z\" }\n },\n \"contacts\": { \"used\": 85000, \"limit\": 150000 },\n \"segments\": { \"used\": 2, \"limit\": 3 },\n \"broadcasts\": { \"used\": 100, \"limit\": null },\n \"ai_credits\": { \"used\": 0, \"limit\": 500, \"next_increase_at\": \"2026-07-18T09:00:00.000Z\" },\n \"automation_runs\": { \"used\": 0, \"limit\": 1000, \"resets_at\": \"2026-08-01T00:00:00.000Z\" },\n \"domains\": { \"used\": 1, \"limit\": 1000 },\n \"rate_limit\": { \"limit\": 10, \"duration\": \"1000ms\" }\n}\n```\n\n## Response Fields\n\n| Field | Type | Nullable | Description |\n|-------|------|----------|--------------|\n| `object` | string | No | Always `\"usage\"` |\n| `emails.daily.used` | number | No | Emails sent + received in the current day (`sent` + `received`) |\n| `emails.daily.limit` | number \\| null | Yes | Plan's daily email cap. `null` when the plan has no daily cap (only a monthly one) |\n| `emails.daily.sent` | number | No | Emails sent today |\n| `emails.daily.received` | number | No | Emails received (inbound) today |\n| `emails.daily.resets_at` | timestamp | No | When the daily counters next reset |\n| `emails.monthly.used` | number | No | Emails sent + received in the current billing month |\n| `emails.monthly.limit` | number | No | Plan's monthly email cap — always a number |\n| `emails.monthly.sent` | number | No | Emails sent this month |\n| `emails.monthly.received` | number | No | Emails received (inbound) this month |\n| `emails.monthly.resets_at` | timestamp | No | When the monthly counters next reset |\n| `contacts.used` | number | No | Total contacts on the account |\n| `contacts.limit` | number | No | Plan's contact cap — always a number |\n| `segments.used` | number | No | Segments created |\n| `segments.limit` | number \\| null | Yes | Plan's segment cap. `null` when the plan doesn't cap segments |\n| `broadcasts.used` | number | No | Broadcasts sent |\n| `broadcasts.limit` | null | Always null | Broadcasts have no plan cap — this field is always `null` |\n| `ai_credits.used` | number | No | AI credits consumed in the current period |\n| `ai_credits.limit` | number \\| null | Yes | Plan's AI credit cap. `null` when the plan grants unlimited AI credits |\n| `ai_credits.next_increase_at` | timestamp \\| null | Yes | When credits are next scheduled to increase. `null` if no increase is scheduled |\n| `automation_runs.used` | number | No | Automation runs in the current period |\n| `automation_runs.limit` | number | No | Plan's automation run cap — always a number |\n| `automation_runs.resets_at` | timestamp | No | When the automation run counter next resets |\n| `domains.used` | number | No | Domains added to the account |\n| `domains.limit` | number \\| null | Yes | Plan's domain cap. `null` when the plan doesn't cap domains |\n| `rate_limit.limit` | number | No | Max API requests allowed per `duration` window |\n| `rate_limit.duration` | string | No | Length of the rate-limit window, e.g. `\"1000ms\"` |\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Assuming every `limit` field can be `null` | Only `emails.daily.limit`, `segments.limit`, `ai_credits.limit`, and `domains.limit` can be `null`. `emails.monthly.limit`, `contacts.limit`, and `automation_runs.limit` are always numbers, and `broadcasts.limit` is always `null` |\n| Reading `emails.daily.used` as \"sent only\" | `used` is `sent` + `received` combined — check `sent`/`received` separately for a breakdown |\n| Expecting `ai_credits.next_increase_at` to always be set | It's `null` when no future increase is scheduled |\n| Calling `.list()` or passing an ID | Usage is a single account-level snapshot — no list, get-by-id, create, update, or delete operations |\n| Treating `rate_limit` as per-endpoint | It reflects the account's default API rate limit, not a per-endpoint override |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5437,"content_sha256":"f3575efcf7fe8bde588e2d791c65e20095f366dbe52b51477441d09774438799"},{"filename":"references/webhooks.md","content":"# Webhooks\n\nReceive real-time notifications when email events occur (delivered, bounced, opened, received, etc.).\n\n## When to Use Webhooks\n\n- Track delivery status in your database\n- Remove bounced addresses from mailing lists\n- Trigger follow-up actions when emails are opened/clicked\n- Process inbound emails (`email.received`)\n- Create alerts for failures or complaints\n- Build custom analytics dashboards\n\n## Event Types\n\n### Email Delivery Events\n\n| Event | Trigger | Use Case |\n|-------|---------|----------|\n| `email.sent` | API request successful, delivery attempted | Confirm email accepted |\n| `email.delivered` | Email reached recipient's mail server | Confirm successful delivery |\n| `email.bounced` | Mail server permanently rejected (hard bounce) | Remove from list, alert user |\n| `email.complained` | Recipient marked as spam | Unsubscribe, review content |\n| `email.opened` | Recipient opened email | Track engagement |\n| `email.clicked` | Recipient clicked a link | Track engagement |\n| `email.delivery_delayed` | Temporary delivery issue (soft bounce) | Monitor, may resolve automatically |\n| `email.failed` | Send error (invalid recipient, quota, etc.) | Debug, alert |\n\n### Inbound Email Events\n\n| Event | Trigger | Use Case |\n|-------|---------|----------|\n| `email.received` | Email received at your inbound domain | Process incoming email, auto-reply, forward |\n\nThe `email.received` payload contains metadata only (sender, recipient, subject, attachment list) — not the email body. Call `resend.emails.receiving.get()` to retrieve the body content. See [receiving.md](receiving.md) for full details.\n\n### Bounce Types\n\n| Type | Event | Action |\n|------|-------|--------|\n| **Hard bounce (Permanent)** | `email.bounced` | Remove address immediately — never retry |\n| **Soft bounce (Transient)** | `email.delivery_delayed` | Monitor — Resend retries automatically |\n| **Undetermined** | `email.bounced` | Treat as hard bounce if repeated |\n\n**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.\n\n| Subtype | Cause |\n|---------|-------|\n| General | Recipient's email provider sent a hard bounce |\n| NoEmail | Address doesn't exist or couldn't be determined |\n\n**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.\n\n| Subtype | Cause | May Resolve If... |\n|---------|-------|-------------------|\n| General | Temporary rejection | Underlying issue clears |\n| MailboxFull | Recipient's inbox at capacity | Recipient frees space |\n| MessageTooLarge | Exceeds provider size limit | You reduce message size |\n| ContentRejected | Contains disallowed content | You modify content |\n| AttachmentRejected | Attachment type/size rejected | You modify attachment |\n\n### Other Events\n\n| Event | Trigger |\n|-------|---------|\n| `domain.created` / `updated` / `deleted` | Domain configuration changes |\n| `contact.created` / `updated` / `deleted` | Contact list changes (not from CSV imports) |\n| `contact.topics.updated` | Contact's topic subscriptions change |\n| `suppression.added` / `removed` | Email address added to or removed from the suppression list |\n| `topic.created` / `updated` / `deleted` | Topic created, updated, or deleted |\n| `inbox.created` / `updated` / `deleted` | Inbox created, its settings changed, or deleted |\n| `inbox.thread.created` | First email of a thread landed in an inbox, inbound or outbound |\n| `inbox.email.received` / `sent` | Inbound email added to a thread, or outbound email accepted for delivery and added to a thread |\n| `inbox.thread.folder.updated` | Thread moved between folders |\n| `inbox.thread.assigned` / `unassigned` | Thread assigned, reassigned, or unassigned |\n| `inbox.thread.labels.updated` | Labels added to or removed from a thread |\n| `inbox.draft.created` / `updated` / `sent` / `deleted` | Draft created, edited, sent, or discarded |\n\nInbox event payloads carry no email or draft body. See [inboxes.md](inboxes.md#webhook-events).\n\n## Setup\n\n1. **Create endpoint** — POST endpoint that returns HTTP 200\n2. **Add webhook** — In Resend dashboard (resend.com/webhooks), add your URL and select events\n3. **Verify signatures** — **REQUIRED** — See [Signature Verification](#signature-verification)\n4. **Test locally** — Use ngrok, Tailscale Funnel, or similar for local development\n\n### Create Webhook via API\n\n**Prefer the API** to create webhooks programmatically instead of using the dashboard. This is faster, less error-prone, and gives you the signing secret directly in the response.\n\n#### Node.js\n\n```typescript\nimport { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.webhooks.create({\n endpoint: 'https://example.com/webhook',\n events: ['email.delivered', 'email.bounced', 'email.received'],\n});\n\nif (error) {\n console.error('Failed to create webhook:', error);\n throw error;\n}\n\n// IMPORTANT: Store the signing secret — you need it to verify incoming webhooks\nconsole.log('Webhook created:', data.id);\nconsole.log('Signing secret:', data.signing_secret); // whsec_xxxxxxxxxx\n```\n\n#### Python\n\n```python\nimport resend\n\nresend.api_key = 're_xxxxxxxxx'\n\nwebhook = resend.Webhooks.create(params={\n \"endpoint\": \"https://example.com/webhook\",\n \"events\": [\"email.delivered\", \"email.bounced\", \"email.received\"],\n})\n\nprint(f\"Webhook created: {webhook['id']}\")\nprint(f\"Signing secret: {webhook['signing_secret']}\")\n```\n\n#### cURL\n\n```bash\ncurl -X POST 'https://api.resend.com/webhooks' \\\n -H 'Authorization: Bearer re_xxxxxxxxx' \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"endpoint\": \"https://example.com/webhook\",\n \"events\": [\"email.delivered\", \"email.bounced\", \"email.received\"]\n }'\n\n# Response:\n# {\n# \"object\": \"webhook\",\n# \"id\": \"4dd369bc-aa82-4ff3-97de-514ae3000ee0\",\n# \"signing_secret\": \"whsec_xxxxxxxxxx\"\n# }\n```\n\nThe `signing_secret` is only returned once when you create the webhook. Store it as `RESEND_WEBHOOK_SECRET` immediately.\n\n### Webhook Management (List, Get, Update, Delete)\n\n| Operation | Node.js | Python |\n|-----------|---------|--------|\n| List | `resend.webhooks.list()` | `resend.Webhooks.list()` |\n| Get | `resend.webhooks.get(id)` | `resend.Webhooks.get(id)` |\n| Update | `resend.webhooks.update(id, params)` | `resend.Webhooks.update(params)` — `webhook_id` inside params |\n| Delete | `resend.webhooks.remove(id)` | `resend.Webhooks.remove(id)` |\n| Rotate Signing Secret | `resend.webhooks.rotateSigningSecret(id)` | `resend.Webhooks.rotate_signing_secret(webhook_id)` |\n| List Events | `resend.webhooks.events.list({ webhookId, ...params })` | `resend.Webhooks.list_events(webhook_id, params?)` |\n| Get Event | `resend.webhooks.events.get({ webhookId, eventId })` | `resend.Webhooks.get_event(webhook_id, event_id)` |\n| Replay Event | `resend.webhooks.events.replay({ webhookId, eventId })` | `resend.Webhooks.replay_event(webhook_id, event_id)` |\n| List Event Attempts | `resend.webhooks.events.attempts.list({ webhookId, eventId, ...params })` | `resend.Webhooks.list_event_attempts(webhook_id, event_id, params?)` |\n\n```typescript\n// List all webhooks\nconst { data: webhooks, error: listError } = await resend.webhooks.list();\n\n// Update endpoint URL or subscribed events\nconst { data: updated, error: updateError } = await resend.webhooks.update(\n '4dd369bc-aa82-4ff3-97de-514ae3000ee0',\n {\n endpoint: 'https://new-domain.com/webhook',\n events: ['email.delivered', 'email.bounced'],\n }\n);\n\n// Delete a webhook\nconst { data: deleted, error: deleteError } = await resend.webhooks.remove('4dd369bc-aa82-4ff3-97de-514ae3000ee0');\n\n// Rotate the signing secret — for 24 hours payloads are signed with both secrets, then only the new one\nconst { data: rotated, error: rotateError } = await resend.webhooks.rotateSigningSecret('4dd369bc-aa82-4ff3-97de-514ae3000ee0');\n// rotated: { object: 'webhook', id, signing_secret: 'whsec_...' }\n// Update RESEND_WEBHOOK_SECRET with rotated.signing_secret right away\n```\n\n**Key gotchas:**\n- `signing_secret` is in the create, get, and rotate responses — read it back with `get`, rotate it if it leaked\n- Update can change `endpoint` and `events` — partial updates supported\n- Use `.remove()` not `.delete()` in the Node.js SDK\n\n### Delivery History (Events and Attempts)\n\nInspect what Resend delivered to a webhook and how the endpoint responded. Use this\nto debug an endpoint that is missing events or returning errors, instead of asking\nthe user to check the dashboard.\n\n```typescript\n// 1. Find the event — most recent first, forward-only pagination\nconst { data: events, error } = await resend.webhooks.events.list({\n webhookId: '4dd369bc-aa82-4ff3-97de-514ae3000ee0',\n limit: 20,\n});\nif (error) throw error;\n// events.data[i]: { id, type, created_at, status }\n// status: 'pending' | 'attempting' | 'success' | 'failed'\nconst eventId = events.data[0].id;\n\n// 2. See the exact payload Resend sent, and when the next retry is due\nconst { data: event } = await resend.webhooks.events.get({\n webhookId: '4dd369bc-aa82-4ff3-97de-514ae3000ee0',\n eventId,\n});\n// event.next_attempt_at is null once status is 'success' or 'failed'\n// event.payload is the JSON body your endpoint received\n\n// 3. Manually replay a failed or missed event — queues one more delivery\nconst { data: replayed, error: replayError } = await resend.webhooks.events.replay({\n webhookId: '4dd369bc-aa82-4ff3-97de-514ae3000ee0',\n eventId,\n});\n// replayed.id is the same event ID — replay does not schedule automatic retries\n\n// 4. See what your endpoint returned on each try\nconst { data: attempts } = await resend.webhooks.events.attempts.list({\n webhookId: '4dd369bc-aa82-4ff3-97de-514ae3000ee0',\n eventId,\n});\n// attempts.data[i]: { id, http_status_code, response, sent_at }\n```\n\nBoth lists take `limit` (1–100, default 20) and `after` (the last `id` of the\nprevious page). There is no `before` — the API rejects it with a 422.\n\nReplay returns a 422 if the webhook is disabled, or if the event's payload is\nno longer available.\n\n## Signature Verification\n\n**Verify webhook signatures on every request.** Without verification, anyone can send fake webhooks to your endpoint.\n\n### Why Verification Matters\n\n- Webhooks are unauthenticated HTTP POST requests\n- Anyone who knows your endpoint URL can send fake events\n- Verification ensures the webhook genuinely came from Resend\n- Unique signatures prevent re-use of old payloads\n\n### Required Headers\n\nEvery webhook includes these headers for verification:\n\n| Header | Purpose |\n|--------|---------|\n| `svix-id` | Unique message identifier |\n| `svix-timestamp` | Unix timestamp when sent |\n| `svix-signature` | Cryptographic signature |\n\n### Get Your Webhook Secret\n\nFind your signing secret in the Resend dashboard:\n1. Go to resend.com/webhooks\n2. Click on your webhook\n3. Copy the signing secret (starts with `whsec_`)\n\nStore it securely as `RESEND_WEBHOOK_SECRET` environment variable.\n\n### Using Resend SDK (Recommended)\n\nExample using Next.js:\n\n```typescript\nimport { Resend } from 'resend';\nimport { NextRequest, NextResponse } from 'next/server';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nexport async function POST(req: NextRequest) {\n try {\n // Important: Use raw body, not parsed JSON\n const payload = await req.text();\n\n // Throws an error if the webhook is invalid\n const event = resend.webhooks.verify({\n payload,\n headers: {\n 'svix-id': req.headers.get('svix-id'),\n 'svix-timestamp': req.headers.get('svix-timestamp'),\n 'svix-signature': req.headers.get('svix-signature'),\n },\n secret: process.env.RESEND_WEBHOOK_SECRET,\n });\n\n switch (event.type) {\n case 'email.delivered':\n // Update database with delivery status\n break;\n case 'email.bounced':\n // Hard bounce — remove from mailing list immediately\n break;\n case 'email.complained':\n // Spam complaint — unsubscribe and flag\n break;\n case 'email.received':\n // Inbound email — retrieve body and process\n const { data: email } = await resend.emails.receiving.get(event.data.email_id);\n break;\n default:\n break;\n }\n\n return new NextResponse('OK', { status: 200 });\n } catch (error) {\n console.error('Webhook verification failed:', error);\n return new NextResponse('Invalid signature', { status: 400 });\n }\n}\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Not verifying signatures | **Always verify** — unverified webhooks can't be trusted |\n| Using parsed JSON body | Use raw request body — JSON parsing breaks signature |\n| Using `express.json()` middleware | Use `express.raw()` for webhook routes |\n| Hardcoding webhook secret | Store in environment variable |\n| Returning non-200 status for valid webhooks | Return 200 OK to acknowledge receipt |\n\n## Retry Schedule\n\nIf your endpoint doesn't return HTTP 200, Resend retries with exponential backoff:\n\n| Attempt | Delay After Failure |\n|---------|---------------------|\n| 1 | Immediate |\n| 2 | 5 seconds |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 | 5 hours |\n| 7 | 10 hours |\n\n**Tip:** Always return 200 quickly, then process asynchronously if needed. You can manually replay failed webhooks from the dashboard, and inspect each attempt's `http_status_code` and response body through the API — see [Delivery History](#delivery-history-events-and-attempts).\n\n## IP Allowlist\n\nIf your firewall requires allowlisting, webhooks come from:\n\n```\n44.228.126.217\n50.112.21.217\n52.24.126.164\n54.148.139.208\n```\n\nIPv6: `2600:1f24:64:8000::/52`\n\n## Local Development\n\nUse tunneling tools to test webhooks locally:\n\n```bash\n# Tailscale Funnel (recommended — permanent URL)\nsudo tailscale funnel 3000\n\n# ngrok\nngrok http 3000\n# Then use the tunnel URL in Resend dashboard\n```\n\n## Event Payload Example\n\n```json\n{\n \"type\": \"email.delivered\",\n \"created_at\": \"2024-01-15T12:00:00.000Z\",\n \"data\": {\n \"email_id\": \"ae2014de-c168-4c61-8267-70d2662a1ce1\",\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Welcome to Acme\"\n }\n}\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14375,"content_sha256":"abb84ba9c6b126eda416cb79e5b6ecb84cb4cb248e4cb15b1351483ef5c7b5e0"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Resend","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Send — Node.js","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"typescript"},"content":[{"text":"import { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nconst { data, error } = await resend.emails.send(\n {\n from: 'Acme \[email protected]>',\n to: ['[email protected]'],\n subject: 'Hello World',\n html: '\u003cp>Email body here\u003c/p>',\n },\n { idempotencyKey: `welcome-email/${userId}` }\n);\n\nif (error) {\n console.error('Failed:', error.message);\n return;\n}\nconsole.log('Sent:', data.id);","type":"text"}]},{"type":"paragraph","content":[{"text":"Key gotcha:","type":"text","marks":[{"type":"strong"}]},{"text":" The Resend Node.js SDK does NOT throw exceptions — it returns ","type":"text"},{"text":"{ data, error }","type":"text","marks":[{"type":"code_inline"}]},{"text":". Always check ","type":"text"},{"text":"error","type":"text","marks":[{"type":"code_inline"}]},{"text":" explicitly instead of using try/catch for API errors.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Send — Python","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"python"},"content":[{"text":"import resend\nimport os\n\nresend.api_key = os.environ[\"RESEND_API_KEY\"]\n\nemail = resend.Emails.send({\n \"from\": \"Acme \[email protected]>\",\n \"to\": [\"[email protected]\"],\n \"subject\": \"Hello World\",\n \"html\": \"\u003cp>Email body here\u003c/p>\",\n}, idempotency_key=f\"welcome-email/{user_id}\")","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Single vs Batch Decision","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Choose","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Single","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"POST /emails","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1 email, needs attachments, needs scheduling","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Batch","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"POST /emails/batch","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2-100 distinct emails, no attachments, no scheduling","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Batch is atomic — if one email fails validation, the entire batch fails. Always validate before sending. Batch does NOT support attachments or ","type":"text"},{"text":"scheduled_at","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Idempotency Keys (Critical for Retries)","type":"text"}]},{"type":"paragraph","content":[{"text":"Prevent duplicate emails when retrying failed requests:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Key Facts","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph"}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Format (single)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\u003cevent-type>/\u003centity-id>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g., ","type":"text"},{"text":"welcome-email/user-123","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Format (batch)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"batch-\u003cevent-type>/\u003cbatch-id>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g., ","type":"text"},{"text":"batch-orders/batch-456","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Expiration","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"24 hours","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Max length","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"256 characters","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Same key + same payload","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Returns original response without resending","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Same key + different payload","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Returns 409 error","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Receive (Node.js)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"typescript"},"content":[{"text":"import { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nexport async function POST(req: Request) {\n const payload = await req.text(); // Must use raw text, not req.json()\n\n const event = resend.webhooks.verify({\n payload,\n headers: {\n 'svix-id': req.headers.get('svix-id'),\n 'svix-timestamp': req.headers.get('svix-timestamp'),\n 'svix-signature': req.headers.get('svix-signature'),\n },\n secret: process.env.RESEND_WEBHOOK_SECRET,\n });\n\n if (event.type === 'email.received') {\n // Webhook has metadata only — call API for body\n const { data: email } = await resend.emails.receiving.get(\n event.data.email_id\n );\n console.log(email.text);\n }\n\n return new Response('OK', { status: 200 });\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Key gotcha:","type":"text","marks":[{"type":"strong"}]},{"text":" Webhook payloads do NOT contain the email body. You must call ","type":"text"},{"text":"resend.emails.receiving.get()","type":"text","marks":[{"type":"code_inline"}]},{"text":" separately.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"What Do You Need?","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Task","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Send a single email","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sending/overview.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/overview.md","title":null}}]},{"text":" — parameters, deliverability, testing","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Send batch emails","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sending/overview.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/overview.md","title":null}}]},{"text":" → ","type":"text"},{"text":"sending/batch-email-examples.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/batch-email-examples.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Full SDK examples","type":"text","marks":[{"type":"strong"}]},{"text":" (Node.js, Python, Go, cURL)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sending/single-email-examples.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/single-email-examples.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Idempotency, retries, error handling","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sending/best-practices.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/best-practices.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Get, list, reschedule, cancel emails, retrieve metrics","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sending/email-management.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/email-management.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Receive inbound emails","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"receiving.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/receiving.md","title":null}}]},{"text":" — domain setup, webhooks, attachments","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage templates","type":"text","marks":[{"type":"strong"}]},{"text":" (CRUD, variables)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"templates.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/templates.md","title":null}}]},{"text":" — lifecycle, aliases, pagination","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set up webhooks","type":"text","marks":[{"type":"strong"}]},{"text":" (events, verification)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"webhooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/webhooks.md","title":null}}]},{"text":" — verification, CRUD, retry schedule, IP allowlist","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage domains","type":"text","marks":[{"type":"strong"}]},{"text":" (create, verify, claim, DNS)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"domains.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/domains.md","title":null}}]},{"text":" — regions, TLS, tracking, claiming, capabilities","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage contacts","type":"text","marks":[{"type":"strong"}]},{"text":" (CRUD, properties)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"contacts.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/contacts.md","title":null}}]},{"text":" — segments, topics, custom properties, bulk CSV import","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Send broadcasts","type":"text","marks":[{"type":"strong"}]},{"text":" (marketing campaigns)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"broadcasts.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/broadcasts.md","title":null}}]},{"text":" — lifecycle, scheduling, template variables","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage API keys","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"api-keys.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/api-keys.md","title":null}}]},{"text":" — permission scoping, domain restrictions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"View API request logs","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"logs.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/logs.md","title":null}}]},{"text":" — list and retrieve API call history, debugging","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Define contact properties","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"contact-properties.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/contact-properties.md","title":null}}]},{"text":" — custom fields for contacts","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage segments","type":"text","marks":[{"type":"strong"}]},{"text":" (contact groups)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"segments.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/segments.md","title":null}}]},{"text":" — broadcast targeting, contact grouping","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage topics","type":"text","marks":[{"type":"strong"}]},{"text":" (subscriptions)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"topics.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/topics.md","title":null}}]},{"text":" — opt-in/out preferences, broadcast filtering","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Create automations","type":"text","marks":[{"type":"strong"}]},{"text":" (event-driven workflows)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"automations.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/automations.md","title":null}}]},{"text":" — steps, connections, runs, conditions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Define and send events","type":"text","marks":[{"type":"strong"}]},{"text":" (automation triggers)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"events.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/events.md","title":null}}]},{"text":" — schemas, payloads, contact association","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check account usage and quotas","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"usage.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/usage.md","title":null}}]},{"text":" — emails, contacts, segments, broadcasts, AI credits, automation runs, domains, rate limit","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage inboxes","type":"text","marks":[{"type":"strong"}]},{"text":" (threads, replies, drafts)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"inboxes.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/inboxes.md","title":null}}]},{"text":" — labels, folders, search, agent settings, inbox webhook events","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Install SDK","type":"text","marks":[{"type":"strong"}]},{"text":" (8+ languages)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"installation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/installation.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set up an AI agent inbox","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Install the ","type":"text"},{"text":"agent-email-inbox","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill — covers security levels for untrusted input","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"SDK Version Requirements","type":"text"}]},{"type":"paragraph","content":[{"text":"Always install the latest SDK version. Older SDKs may be missing ","type":"text"},{"text":"webhooks.verify()","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"emails.receiving.get()","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"domains.claims.*","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"If the project already has a Resend SDK installed","type":"text","marks":[{"type":"strong"}]},{"text":", check its version against the minimum versions in ","type":"text"},{"text":"installation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/installation.md","title":null}}]},{"text":" and upgrade if it's below.","type":"text"}]}]},{"type":"paragraph","content":[{"text":"See ","type":"text"},{"text":"installation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/installation.md","title":null}}]},{"text":" for the minimum version per language, full installation commands, language detection, and cURL fallback.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Common Setup","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"API Key","type":"text"}]},{"type":"paragraph","content":[{"text":"Store in environment variable — never hardcode:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"export RESEND_API_KEY=re_xxxxxxxxx","type":"text"}]},{"type":"paragraph","content":[{"text":"Get your key at ","type":"text"},{"text":"resend.com/api-keys","type":"text","marks":[{"type":"link","attrs":{"href":"https://resend.com/api-keys","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Detect Project Language","type":"text"}]},{"type":"paragraph","content":[{"text":"Check for these files: ","type":"text"},{"text":"package.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Node.js), ","type":"text"},{"text":"requirements.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"pyproject.toml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Python), ","type":"text"},{"text":"go.mod","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Go), ","type":"text"},{"text":"Gemfile","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Ruby), ","type":"text"},{"text":"composer.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (PHP), ","type":"text"},{"text":"Cargo.toml","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Rust), ","type":"text"},{"text":"pom.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"build.gradle","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Java), ","type":"text"},{"text":"*.csproj","type":"text","marks":[{"type":"code_inline"}]},{"text":" (.NET).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Common Mistakes","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"#","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mistake","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Retrying without idempotency key","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always include idempotency key — prevents duplicate sends on retry. Format: ","type":"text"},{"text":"\u003cevent-type>/\u003centity-id>","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Not verifying webhook signatures","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always verify with ","type":"text"},{"text":"resend.webhooks.verify()","type":"text","marks":[{"type":"code_inline"}]},{"text":" — unverified events can't be trusted","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Template variable name mismatch","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Variable names are case-sensitive — must match the template definition exactly. Use triple mustache ","type":"text"},{"text":"{{{VAR}}}","type":"text","marks":[{"type":"code_inline"}]},{"text":" syntax","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Expecting email body in webhook payload","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Webhooks contain metadata only — call ","type":"text"},{"text":"resend.emails.receiving.get()","type":"text","marks":[{"type":"code_inline"}]},{"text":" for body content","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using try/catch for Node.js SDK errors","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SDK returns ","type":"text"},{"text":"{ data, error }","type":"text","marks":[{"type":"code_inline"}]},{"text":" — check ","type":"text"},{"text":"error","type":"text","marks":[{"type":"code_inline"}]},{"text":" explicitly, don't wrap in try/catch","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Using batch for emails with attachments","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Batch doesn't support attachments — use single sends instead","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing with fake emails ([email protected])","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]},{"text":" — fake addresses bounce and hurt reputation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"8","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sending with draft template","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Templates must be published before sending — call ","type":"text"},{"text":".publish()","type":"text","marks":[{"type":"code_inline"}]},{"text":" first","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"9","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"html","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" + ","type":"text","marks":[{"type":"strong"}]},{"text":"template","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" in same send call","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mutually exclusive — remove ","type":"text"},{"text":"html","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"text","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"react","type":"text","marks":[{"type":"code_inline"}]},{"text":" when using template","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"10","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MX record not lowest priority for inbound","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ensure Resend's MX has the lowest number (highest priority) or emails won't route","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"11","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"403 when sending from ","type":"text","marks":[{"type":"strong"}]},{"text":"resend.dev","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The default ","type":"text"},{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a sandbox — it can only deliver to your Resend account email. Verify your own domain first","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"12","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"403 domain mismatch","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"from","type":"text","marks":[{"type":"code_inline"}]},{"text":" address domain must exactly match a verified domain. Verified ","type":"text"},{"text":"send.acme.com","type":"text","marks":[{"type":"code_inline"}]},{"text":" but sending from ","type":"text"},{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]},{"text":" will fail","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"13","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Calling Resend API from the browser (CORS)","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The API does not support CORS — this is intentional to protect your API key. Always call from server-side (API routes, serverless functions)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"14","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"401 ","type":"text","marks":[{"type":"strong"}]},{"text":"restricted_api_key","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A sending-only API key was used on a non-sending endpoint (domains, contacts, etc.). Create a full-access key instead","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Cross-Cutting Concerns","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Send + Receive Together","type":"text"}]},{"type":"paragraph","content":[{"text":"Auto-replies, email forwarding, or any receive-then-send workflow requires both capabilities:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Set up inbound domain first (see ","type":"text"},{"text":"receiving.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/receiving.md","title":null}}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Set up sending (see ","type":"text"},{"text":"sending/overview.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/overview.md","title":null}}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Note: batch sending does NOT support attachments or scheduling — use single sends when forwarding with attachments","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"AI Agent Inbox","type":"text"}]},{"type":"paragraph","content":[{"text":"If your system processes untrusted email content and takes actions (refunds, database changes, forwarding), install the ","type":"text"},{"text":"agent-email-inbox","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill. This applies whether or not AI is involved — any system interpreting freeform email content from external senders needs security measures.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Marketing Emails","type":"text"}]},{"type":"paragraph","content":[{"text":"The sending capabilities in this skill are for ","type":"text"},{"text":"transactional email","type":"text","marks":[{"type":"strong"}]},{"text":" (receipts, confirmations, notifications). For marketing campaigns to large subscriber lists with unsubscribe links and engagement tracking, use Resend Broadcasts — see ","type":"text"},{"text":"broadcasts.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/broadcasts.md","title":null}}]},{"text":" for the API.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Domain Warm-up","type":"text"}]},{"type":"paragraph","content":[{"text":"New domains must gradually increase sending volume. Day 1 limit: ~150 emails (new domain) or ~1,000 (existing domain). See the warm-up schedule in ","type":"text"},{"text":"sending/overview.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sending/overview.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Testing","type":"text"}]},{"type":"paragraph","content":[{"text":"Never test with fake addresses at real email providers","type":"text","marks":[{"type":"strong"}]},{"text":" ([email protected], [email protected]) — they bounce and destroy sender reputation.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Address","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Result","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Simulates successful delivery","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Simulates hard bounce","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Simulates spam complaint","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Suppression List","type":"text"}]},{"type":"paragraph","content":[{"text":"Resend automatically suppresses hard-bounced and spam-complained addresses. Sending to suppressed addresses fires the ","type":"text"},{"text":"email.suppressed","type":"text","marks":[{"type":"code_inline"}]},{"text":" webhook event instead of attempting delivery. Manage in Dashboard → Suppressions.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Webhook Event Types","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Event","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Trigger","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.sent","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"API request successful","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.delivered","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reached recipient's mail server","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.bounced","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Permanently rejected (hard bounce)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.complained","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Recipient marked as spam","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.opened","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"email.clicked","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Recipient engagement","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.delivery_delayed","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Soft bounce, Resend retries","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"email.received","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Inbound email arrived","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"domain.*","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"contact.*","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Domain/contact changes","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"See ","type":"text"},{"text":"webhooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/webhooks.md","title":null}}]},{"text":" for full details, signature verification, and retry schedule.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Error Handling Quick Reference","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Code","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Action","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"400, 422","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix request parameters, don't retry","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"401","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check API key — ","type":"text"},{"text":"restricted_api_key","type":"text","marks":[{"type":"code_inline"}]},{"text":" means sending-only key used on non-sending endpoint","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"403","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verify domain ownership — common causes: ","type":"text"},{"text":"resend.dev","type":"text","marks":[{"type":"code_inline"}]},{"text":" sandbox, ","type":"text"},{"text":"from","type":"text","marks":[{"type":"code_inline"}]},{"text":" domain mismatch, unverified domain","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"409","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Idempotency conflict — use new key or fix payload","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"429","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rate limited — retry with exponential backoff (default rate limit: 2 req/s)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"500","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Server error — retry with exponential backoff","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Resources","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Resend Documentation","type":"text","marks":[{"type":"link","attrs":{"href":"https://resend.com/docs","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"API Reference","type":"text","marks":[{"type":"link","attrs":{"href":"https://resend.com/docs/api-reference","title":null}}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dashboard","type":"text","marks":[{"type":"link","attrs":{"href":"https://resend.com/emails","title":null}}]}]}]}]}]},"metadata":{"date":"2026-11-02","name":"resend","author":"@skillopedia","inputs":[{"name":"RESEND_API_KEY","required":true,"description":"Resend API key for sending and receiving emails. Get yours at https://resend.com/api-keys"},{"name":"RESEND_WEBHOOK_SECRET","required":false,"description":"Webhook signing secret for verifying event payloads. Found in the Resend dashboard under Webhooks after creating an endpoint."}],"source":{"stars":209,"repo_name":"resend-skills","origin_url":"https://github.com/resend/resend-skills/blob/HEAD/skills/resend/SKILL.md","repo_owner":"resend","body_sha256":"56b5f80dc21a4ef7e7b92586d98af3e00dfdad802fc7be98fd143d95092cdfb3","cluster_key":"ba2ecf56b5532bb9f587847b64044a51cf2acbd517cd05ab068ea61f723f3ac8","clean_bundle":{"format":"clean-skill-bundle-v1","source":"resend/resend-skills/skills/resend/SKILL.md","text_bytes":183057,"source_commit":"b2ae7b1798b70134faf679f35fc05b4331462988","skills_sh_rank":1383,"attachment_count":22,"text_attachments":22,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/resend/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"integrations-apis","category_label":"Integrations"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"integrations-apis","metadata":{"author":"resend","source":"https://github.com/resend/resend-skills","version":"3.16.0","homepage":"https://resend.com/agent-skills","openclaw":{"links":{"repository":"https://github.com/resend/resend-skills","documentation":"https://resend.com/docs/resend-skill"},"envVars":[{"name":"RESEND_API_KEY","required":true,"description":"Resend API key for sending and receiving emails"},{"name":"RESEND_WEBHOOK_SECRET","required":false,"description":"Webhook signing secret for verifying event payloads"}],"requires":{"env":["RESEND_API_KEY"]},"primaryEnv":"RESEND_API_KEY"}},"import_tag":"skills-sh-top20k-2026-11","references":["sending","receiving.md","templates.md","webhooks.md","domains.md","contacts.md","broadcasts.md","api-keys.md","logs.md","contact-properties.md","segments.md","topics.md","automations.md","events.md","usage.md","inboxes.md","installation.md","fetch-all-templates.mjs"],"description":"Use when working with the Resend email API — sending transactional emails (single or batch), receiving inbound emails via webhooks, managing email templates, tracking delivery events, managing domains, contacts, broadcasts, webhooks, API keys, inboxes (threads, labels, drafts, replies), automations, events, viewing API request logs, checking account usage and quotas, or setting up the Resend SDK. Always use this skill when the user mentions Resend, even for simple tasks like \"send an email with Resend\" — the skill contains critical gotchas (idempotency keys, webhook verification, template variable syntax) that prevent common production issues."}},"renderedAt":1793671718084}

Resend Quick Send — Node.js Key gotcha: The Resend Node.js SDK does NOT throw exceptions — it returns . Always check explicitly instead of using try/catch for API errors. Quick Send — Python Single vs Batch Decision | Choose | When | |--------|------| | Single ( ) | 1 email, needs attachments, needs scheduling | | Batch ( ) | 2-100 distinct emails, no attachments, no scheduling | Batch is atomic — if one email fails validation, the entire batch fails. Always validate before sending. Batch does NOT support attachments or . Idempotency Keys (Critical for Retries) Prevent duplicate emails when r…