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', // 'critical'|'urgent'|'high'|'normal'|'low'|'deferred'|'backlog' (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 | 'critical', 'urgent', 'high', 'normal' (default), 'low', 'deferred', or 'backlog'. Unrecognized values are treated as 'normal'. |
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 ("Daily 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',
priority: 'low', // 'critical'|'urgent'|'high'|'normal'|'low'|'deferred'|'backlog' (default: 'normal')
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 |
priority | string | No | 'critical', 'urgent', 'high', 'normal' (default), 'low', 'deferred', or 'backlog' — queue priority for the whole batch, draining highest-urgency first in that order. 'critical'/'urgent'/'high' bypass send pacing; 'normal' and below are paced. Unrecognized values are treated as 'normal'. Use 'backlog'/'deferred' for large backfill campaigns so transactional sends jump ahead. |
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() |
Routing campaigns across vendors? See Global Quota for the full check → send → fall back recipe.
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
'bounced@example.com',
'complained@example.com'
],
recipients: [ // per-recipient messageId mapping
{ toEmail: 'a@example.com', messageId: '660e8400-...', status: 'queued' },
{ toEmail: 'bounced@example.com', messageId: '660e8401-...', status: 'suppressed' }
],
duplicatesRemoved: 3 // duplicate toEmails that were deduplicated
}Duplicate responses are fully populated
When an idempotencyKey is provided and the same request is replayed, the response returns duplicate: true with the same recipients and suppressedEmails as the original. The per-recipient messageId mapping is rebuilt from the job's stored recipient rows (it isn't kept in the idempotency cache), so idempotent retries don't lose it.
On a replay, each recipient's status reflects its live state at replay time — if the worker has already processed the job, you may see sent, delivered, failed, etc. rather than queued. (suppressed recipients stay suppressed.) The first, live response always reports sendable recipients as queued.
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'
}
]
}Check a Single Message's Status
Look up the current status of one message by its messageId — the ID returned from send() / bulk() and included in webhook payloads. This is the way to reconcile a message whose webhook was missed.
Accepts only a message ID — not a job ID or SES message ID. The message must belong to the tenant your API key is scoped to, otherwise a NotFoundError is thrown.
const result = await outbound.email.getMessageStatus('660e8400-e29b-41d4-a716-446655440001');Response
{
recipient: {
message_id: '660e8400-e29b-41d4-a716-446655440001',
job_id: '550e8400-e29b-41d4-a716-446655440000',
recipient_email: 'alice@example.com',
from_email: 'hello@yourdomain.com',
sender_name: 'Your App',
subject: 'Welcome!',
status: 'delivered',
ses_message_id: 'aws-ses-message-id',
error_message: null,
created_at: '2026-03-08T10:00:00.000Z',
updated_at: '2026-03-08T10:01:00.000Z'
}
}The status reflects the latest event recorded for the message by the SES event pipeline (configuration-set event destinations → stored events), so the stored
statusalready reflects the most recent delivery/open/click/bounce SES has reported.
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 |
Fetch a Message's Sent Content
Fetch the content that was sent for one message by its messageId — the subject, envelope and htmlBody / textBody. This is the same content the dashboard renders in its Preview, so you can reconstruct or re-display a sent email in your own UI.
htmlBody is returned as sent, including the unsubscribe footer the platform appends. Attachments are not inlined. Accepts only a message ID — not a job ID or SES message ID — and the message must belong to the tenant your API key is scoped to, otherwise a NotFoundError is thrown.
const content = await outbound.email.getMessageContent('660e8400-e29b-41d4-a716-446655440001');Response
{
messageId: '660e8400-e29b-41d4-a716-446655440001',
jobId: '550e8400-e29b-41d4-a716-446655440000',
status: 'delivered',
subject: 'Welcome to Acme',
fromEmail: 'hello@yourdomain.com',
senderName: 'Your App',
replyTo: 'support@yourdomain.com',
toEmail: 'alice@example.com',
ccEmail: null,
bccEmail: null,
htmlBody: '<html>...as sent, incl. unsubscribe footer...</html>',
textBody: 'Plain-text alternative, or null',
createdAt: '2026-03-08T10:00:00.000Z',
sentAt: '2026-03-08T10:00:02.000Z'
}Rendering the Preview
const { htmlBody, subject } = await outbound.email.getMessageContent(messageId);
// e.g. render inside a sandboxed iframe in your dashboard
iframe.srcdoc = htmlBody ?? '';
document.querySelector('#subject').textContent = subject ?? '(no subject)';Possible Errors
| Error | Status | Cause |
|---|---|---|
BadRequestError | 400 | messageId is not a valid UUID |
NotFoundError | 404 | No message with this Message ID for this 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 |