Email
Send single and bulk emails, and track delivery status by job ID.
All examples assume you've set the API key in the constructor. For multi-tenant usage, pass { apiKey } as the last argument to any method. See Configuration.
Send Single Email
Send one email to one recipient.
const result = await outbound.email.send({
toEmail: 'user@example.com',
fromEmail: 'noreply@yourcompany.com',
emailSubject: 'Your order has shipped',
htmlBody: '<h1>Order #1234 Shipped</h1><p>Your package is on its way.</p>',
textBody: 'Order #1234 Shipped. Your package is on its way.',
senderName: 'YourCompany',
replyTo: 'support@yourcompany.com',
});Full Parameters
await outbound.email.send({
// Required
toEmail: 'user@example.com',
fromEmail: 'noreply@yourcompany.com', // must be a verified domain
emailSubject: 'Subject line',
htmlBody: '<h1>Email content</h1>',
// Optional
textBody: 'Plain text fallback',
senderName: 'Display Name', // shows as "Display Name <noreply@...>"
replyTo: 'support@yourcompany.com',
ccEmail: 'cc@example.com',
bccEmail: 'bcc@example.com',
priority: 'high', // 'low' | 'normal' | 'high' (default: 'normal')
idempotencyKey: 'order-shipped-1234', // prevents duplicate sends
metadata: { orderId: '1234', userId: 'u_abc' },
headers: { 'X-Custom-Header': 'value' },
tracking: { opens: true, clicks: true },
attachments: [
{
filename: 'invoice.pdf',
content: 'base64-encoded-content-here',
contentType: 'application/pdf',
},
],
});Parameters Reference
| Field | Type | Required | Description |
|---|---|---|---|
toEmail | string | Yes | Recipient email address |
fromEmail | string | Yes | Sender email — must be a verified domain assigned to your tenant |
emailSubject | string | Yes | Email subject line |
htmlBody | string | Yes | HTML email body |
textBody | string | No | Plain text alternative (recommended for deliverability) |
senderName | string | No | Display name shown to recipient |
replyTo | string | No | Reply-to email address |
ccEmail | string | No | CC recipient |
bccEmail | string | No | BCC recipient |
priority | string | No | 'low', 'normal' (default), or 'high' |
idempotencyKey | string | No | Unique key to prevent duplicate sends — expires after 24 hours (see below) |
campaignId | string | No | Tag this email as part of a campaign — enables bulk cancellation via email.cancel() |
metadata | object | No | Custom key-value pairs stored with the email |
headers | object | No | Custom email headers |
tracking | object | No | Open and click tracking configuration |
attachments | Attachment[] | No | File attachments (max 10MB each, 25MB total) |
Response
{
message: 'Email queued successfully',
jobId: '550e8400-e29b-41d4-a716-446655440000',
messageId: '660e8400-e29b-41d4-a716-446655440001'
}| Field | Description |
|---|---|
jobId | Unique job ID — use this to check delivery status |
messageId | Unique message ID for this specific recipient |
Possible Errors
| Error | Status | Cause |
|---|---|---|
BadRequestError | 400 | Missing required fields, invalid email format |
AuthenticationError | 401 | Invalid or missing API key |
ForbiddenError | 403 | Domain not verified, tenant suspended |
ConflictError | 409 | Idempotency key is currently being processed by another request |
RateLimitError | 429 | Per-second rate limit exceeded, or the global AWS SES daily sending quota is exhausted ("AWS SES daily sending quota exhausted (X/Y). Try again later.") |
Idempotency
Use idempotencyKey to safely retry without sending duplicate emails. Keys are scoped per tenant and expire after 24 hours — after that, the same key can be reused.
// First call — email is queued
const result1 = await outbound.email.send({
toEmail: 'user@example.com',
fromEmail: 'noreply@company.com',
emailSubject: 'Order Confirmation',
htmlBody: '<h1>Order confirmed</h1>',
idempotencyKey: 'order-confirm-1234',
});
// Second call with same key — returns the exact cached response, no duplicate sent
const result2 = await outbound.email.send({
toEmail: 'user@example.com',
fromEmail: 'noreply@company.com',
emailSubject: 'Order Confirmation',
htmlBody: '<h1>Order confirmed</h1>',
idempotencyKey: 'order-confirm-1234',
});
// result1.jobId === result2.jobId ✓
// result2.duplicate === trueHow it works
- The key is locked when the request starts processing. If a second request arrives with the same key while the first is still in-flight, it returns
409 Conflict. - Once the first request completes, subsequent calls return the exact same response (same
jobId,messageId) withduplicate: true. - After 24 hours, the key expires and can be reused for a new send.
- If the original request fails (validation error, SES quota exhausted, etc.), the key is released so you can retry immediately.
- Keys are stored in Postgres (durable) and cached in Redis for fast lookups. Redis eviction under memory pressure is safe — the system falls back to Postgres automatically.
Generating Good Idempotency Keys
The key should be deterministic — the same logical send should always produce the same key. This way, retries automatically deduplicate without you tracking state.
Single email — combine the purpose + recipient:
import { createHash } from 'crypto';
// Transactional: one email type per order
const key = createHash('sha256')
.update(`order-confirmation:${orderId}:${recipientEmail}`)
.digest('hex');
await outbound.email.send({
toEmail: recipientEmail,
fromEmail: 'noreply@company.com',
emailSubject: 'Order Confirmed',
htmlBody: '<h1>Thanks for your order</h1>',
idempotencyKey: key,
});Bulk email — use the batch identity (a single key covers the whole batch):
// Campaign: one key for the entire batch
const key = createHash('sha256')
.update(`newsletter:march-2026:batch-1`)
.digest('hex');
await outbound.email.bulk({
fromEmail: 'news@company.com',
emailSubject: 'March Newsletter',
idempotencyKey: key,
emails: recipients,
});Same key = same request
The idempotency key represents the entire API call, not individual recipients. If you send 100 emails with key "abc", then call again with 300 emails and the same key "abc", the second call returns the cached response from the first — it does not send to the extra 200.
Example: Bulk Sends for a Campaign
Suppose you're running campaign COM-0001 and sending to 1,200 recipients. Since the bulk API accepts max 500 per call, you'd split into 3 batches. Each batch needs its own idempotency key:
import { createHash } from 'crypto';
const campaignId = 'COM-0001';
const allRecipients = [ /* 1,200 recipients */ ];
// Split into batches of 500
const batchSize = 500;
const batches = [];
for (let i = 0; i < allRecipients.length; i += batchSize) {
batches.push(allRecipients.slice(i, i + batchSize));
}
// Send each batch with a unique, deterministic key
for (let i = 0; i < batches.length; i++) {
const key = createHash('sha256')
.update(`${campaignId}:batch-${i + 1}`)
.digest('hex');
// COM-0001:batch-1 → "a3f8b2..."
// COM-0001:batch-2 → "7c1d4e..."
// COM-0001:batch-3 → "9e2f1a..."
await outbound.email.bulk({
fromEmail: 'campaigns@yourcompany.com',
emailSubject: 'Exclusive Offer from YourCompany',
senderName: 'YourCompany',
idempotencyKey: key,
emails: batches[i].map(r => ({
toEmail: r.email,
htmlBody: `<h1>Hi ${r.name}</h1><p>Your exclusive offer...</p>`,
})),
});
}
// Safe to retry the entire loop — batches that already succeeded
// will return the cached response with duplicate: trueWhy this works:
- Each batch gets a deterministic key based on campaign + batch number
- If your process crashes after batch 2, you can restart the loop — batches 1 and 2 return instantly as duplicates, batch 3 sends normally
- The same campaign can never accidentally double-send a batch within 24 hours
Attachments
import { readFileSync } from 'fs';
await outbound.email.send({
toEmail: 'user@example.com',
fromEmail: 'noreply@company.com',
emailSubject: 'Your Invoice',
htmlBody: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: 'invoice-march-2026.pdf',
content: readFileSync('./invoice.pdf').toString('base64'),
contentType: 'application/pdf',
},
{
filename: 'logo.png',
content: readFileSync('./logo.png').toString('base64'),
contentType: 'image/png',
},
],
});Attachment Limits
- Max 10MB per individual attachment
- Max 25MB total per email
- Content must be base64 encoded
Send Bulk Emails
Send up to 500 emails in a single API call. Each recipient can have a different HTML body and subject.
const result = await outbound.email.bulk({
fromEmail: 'noreply@yourcompany.com',
emailSubject: 'Monthly Newsletter',
senderName: 'YourCompany',
replyTo: 'newsletter@yourcompany.com',
emails: [
{
toEmail: 'alice@example.com',
htmlBody: '<h1>Hi Alice</h1><p>Your personalized content here.</p>',
},
{
toEmail: 'bob@example.com',
htmlBody: '<h1>Hi Bob</h1><p>Different content for Bob.</p>',
subject: 'Bob, check this out!', // overrides emailSubject for this recipient
},
{
toEmail: 'charlie@example.com',
htmlBody: '<h1>Hi Charlie</h1>',
metadata: { segment: 'vip' },
},
],
});Parameters
| Field | Type | Required | Description |
|---|---|---|---|
fromEmail | string | Yes | Sender email (verified domain) |
emailSubject | string | Yes | Default subject for all recipients |
emails | BulkEmailRecipient[] | Yes | Array of recipients (max 500) |
senderName | string | No | Display name |
replyTo | string | No | Reply-to address |
idempotencyKey | string | No | Prevents duplicate bulk sends — expires after 24 hours |
campaignId | string | No | Tag this batch as part of a campaign — enables bulk cancellation via email.cancel() |
Recipient Object
| Field | Type | Required | Description |
|---|---|---|---|
toEmail | string | Yes | Recipient email address |
htmlBody | string | Yes | HTML body for this specific recipient |
subject | string | No | Override the default emailSubject for this recipient |
textBody | string | No | Plain text alternative |
ccEmail | string | No | CC recipient |
bccEmail | string | No | BCC recipient |
metadata | object | No | Custom metadata for this recipient |
Response
{
message: 'Bulk email queued successfully',
jobId: '550e8400-e29b-41d4-a716-446655440000',
recipientCount: 485, // actual emails that will be sent
suppressedCount: 12, // emails filtered out (on suppression list)
suppressedEmails: [ // which emails were suppressed — see note below
'bounced@example.com',
'complained@example.com'
],
duplicatesRemoved: 3 // duplicate toEmails that were deduplicated
}suppressedEmails on duplicate responses
When an idempotencyKey is provided and the same request is replayed, the response returns duplicate: true but does not include suppressedEmails (only the count is cached). Always default the field:
const { suppressedEmails = [], suppressedCount } = await outbound.email.bulk({ ... });Automatic Filtering
The platform automatically handles:
- Deduplication — If you include the same
toEmailtwice, only the first occurrence is kept - Suppression filtering — Emails on your suppression list (bounces, complaints, manual suppressions, unsubscribes) are silently removed from the send but persisted as
droppedrecipient rows on the job, so you can audit them viaemail.status(jobId) - Quota reservation — Only valid, non-suppressed recipients count against your quota
All-suppressed batches still succeed
If every recipient in a bulk call is on the suppression list, the request still returns 202 Accepted with a jobId. No emails are sent, recipientCount is 0, and every suppressed address is persisted as a dropped row — query the job to see them.
Possible Errors
| Error | Status | Cause |
|---|---|---|
BadRequestError | 400 | Empty emails array, exceeds 500 limit, missing required fields |
ForbiddenError | 403 | Domain not verified, tenant suspended |
ConflictError | 409 | Idempotency key is currently being processed by another request |
RateLimitError | 429 | Exceeded rate limit, or the global AWS SES daily sending quota is exhausted |
Check Job Status
Track the delivery status of every recipient in a job.
const status = await outbound.email.status('550e8400-e29b-41d4-a716-446655440000');Response
{
job: {
id: '550e8400-e29b-41d4-a716-446655440000',
tenant_id: 'uuid',
total_recipients: 500,
status: 'completed', // 'queued' | 'processing' | 'completed' | 'failed'
priority: 'normal',
created_at: '2026-03-08T10:00:00.000Z',
updated_at: '2026-03-08T10:01:00.000Z'
},
recipients: [
{
message_id: '660e8400-e29b-41d4-a716-446655440001',
recipient_email: 'alice@example.com',
status: 'delivered',
ses_message_id: 'aws-ses-message-id',
error_message: null,
created_at: '2026-03-08T10:00:00.000Z'
},
{
message_id: '660e8400-e29b-41d4-a716-446655440002',
recipient_email: 'bad-address@example.com',
status: 'bounced',
ses_message_id: 'aws-ses-message-id',
error_message: null,
created_at: '2026-03-08T10:00:00.000Z'
},
{
message_id: '660e8400-e29b-41d4-a716-446655440003',
recipient_email: 'invalid@nonexistent.tld',
status: 'failed',
ses_message_id: null,
error_message: 'Email address not verified',
created_at: '2026-03-08T10:00:00.000Z'
}
]
}Recipient Status Reference
| Status | Description | Final? |
|---|---|---|
queued | Waiting in the queue | No |
processing | Being sent to AWS SES | No |
sent | SES accepted the email | No |
delivered | Delivered to recipient's mailbox | Yes |
bounced | Hard or soft bounce | Yes |
complained | Recipient reported as spam | Yes |
opened | Recipient opened the email | No (can become clicked) |
clicked | Recipient clicked a link | Yes |
failed | Failed to send to SES | Yes |
cancelled | Cancelled before sending via email.cancel() | Yes |
dropped | Recipient was on the suppression list at submit time — never attempted. error_message is Suppressed: <bounce|complaint|manual|unsubscribe> | Yes |
Status Updates
Statuses update asynchronously as AWS SES sends delivery notifications back to the platform. sent → delivered can take a few seconds to a few minutes. opened and clicked events depend on tracking being enabled and may arrive hours later.
Polling Example
async function waitForDelivery(jobId: string, maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i++) {
const { recipients } = await outbound.email.status(jobId);
const allDone = recipients.every(r =>
['delivered', 'bounced', 'complained', 'failed', 'cancelled', 'dropped'].includes(r.status)
);
if (allDone) {
const delivered = recipients.filter(r => r.status === 'delivered').length;
console.log(`${delivered}/${recipients.length} delivered`);
return recipients;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error('Timed out waiting for delivery');
}Possible Errors
| Error | Status | Cause |
|---|---|---|
NotFoundError | 404 | Job ID doesn't exist or belongs to a different tenant |
AuthenticationError | 401 | Invalid API key |
Cancel a Campaign or Job
Stop queued emails before they are sent. Use this when emails were triggered for the wrong audience or need to be aborted.
// Cancel all jobs belonging to a campaign
await outbound.email.cancel({ campaignId: 'launch-2026-q2' });
// Cancel a single specific job
await outbound.email.cancel({ jobId: '550e8400-e29b-41d4-a716-446655440000' });How cancellation works
Only recipients still in queued or processing state are cancelled. Recipients already sent or delivered cannot be recalled — cancellation is not an unsend.
The platform does two things on cancel:
- Marks recipients as
cancelledin the database — the worker checks this and skips them - Removes the job from the queue if it hasn't started yet (
removedFromQueuein the response tells you how many were removed this way)
Using campaignId for bulk cancellation
Pass campaignId when sending to group multiple API calls under one campaign. A single cancel() call then stops all of them:
// Send in 3 batches, all tagged to the same campaign
for (let i = 0; i < batches.length; i++) {
await outbound.email.bulk({
fromEmail: 'campaigns@company.com',
emailSubject: 'Spring Sale',
campaignId: 'spring-sale-2026', // same ID across all batches
emails: batches[i],
});
}
// Oops — wrong audience. Cancel everything.
const result = await outbound.email.cancel({ campaignId: 'spring-sale-2026' });
console.log(`Cancelled ${result.cancelledRecipients} recipients across ${result.cancelledJobs} jobs`);This also works with template sends:
await outbound.templates.bulkSend({
templateId: 'promo-template',
fromEmail: 'campaigns@company.com',
campaignId: 'spring-sale-2026',
recipients: [ ... ],
});
await outbound.email.cancel({ campaignId: 'spring-sale-2026' });Parameters
| Field | Type | Required | Description |
|---|---|---|---|
campaignId | string | One of | Cancel all jobs tagged with this campaign ID |
jobId | string | One of | Cancel a single specific job |
Response
{
message: 'Cancelled successfully',
cancelledJobs: 3, // number of EmailJob records cancelled
cancelledRecipients: 1420, // individual recipients set to 'cancelled'
removedFromQueue: 2 // jobs removed from BullMQ before processing started
}Possible Errors
| Error | Status | Cause |
|---|---|---|
BadRequestError | 400 | Neither campaignId nor jobId provided |
NotFoundError | 404 | No jobs found for this tenant matching the criteria |
ConflictError | 409 | All matching jobs are already completed, failed, or cancelled |