Skip to content

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.

ts
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

ts
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

FieldTypeRequiredDescription
toEmailstringYesRecipient email address
fromEmailstringYesSender email — must be a verified domain assigned to your tenant
emailSubjectstringYesEmail subject line
htmlBodystringYesHTML email body
textBodystringNoPlain text alternative (recommended for deliverability)
senderNamestringNoDisplay name shown to recipient
replyTostringNoReply-to email address
ccEmailstringNoCC recipient
bccEmailstringNoBCC recipient
prioritystringNo'low', 'normal' (default), or 'high'
idempotencyKeystringNoUnique key to prevent duplicate sends — expires after 24 hours (see below)
campaignIdstringNoTag this email as part of a campaign — enables bulk cancellation via email.cancel()
metadataobjectNoCustom key-value pairs stored with the email
headersobjectNoCustom email headers
trackingobjectNoOpen and click tracking configuration
attachmentsAttachment[]NoFile attachments (max 10MB each, 25MB total)

Response

ts
{
  message: 'Email queued successfully',
  jobId: '550e8400-e29b-41d4-a716-446655440000',
  messageId: '660e8400-e29b-41d4-a716-446655440001'
}
FieldDescription
jobIdUnique job ID — use this to check delivery status
messageIdUnique message ID for this specific recipient

Possible Errors

ErrorStatusCause
BadRequestError400Missing required fields, invalid email format
AuthenticationError401Invalid or missing API key
ForbiddenError403Domain not verified, tenant suspended
ConflictError409Idempotency key is currently being processed by another request
RateLimitError429Per-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.

ts
// 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 === true

How 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) with duplicate: 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:

ts
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):

ts
// 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:

ts
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: true

Why 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

ts
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.

ts
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

FieldTypeRequiredDescription
fromEmailstringYesSender email (verified domain)
emailSubjectstringYesDefault subject for all recipients
emailsBulkEmailRecipient[]YesArray of recipients (max 500)
senderNamestringNoDisplay name
replyTostringNoReply-to address
idempotencyKeystringNoPrevents duplicate bulk sends — expires after 24 hours
campaignIdstringNoTag this batch as part of a campaign — enables bulk cancellation via email.cancel()

Recipient Object

FieldTypeRequiredDescription
toEmailstringYesRecipient email address
htmlBodystringYesHTML body for this specific recipient
subjectstringNoOverride the default emailSubject for this recipient
textBodystringNoPlain text alternative
ccEmailstringNoCC recipient
bccEmailstringNoBCC recipient
metadataobjectNoCustom metadata for this recipient

Response

ts
{
  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:

ts
const { suppressedEmails = [], suppressedCount } = await outbound.email.bulk({ ... });

Automatic Filtering

The platform automatically handles:

  1. Deduplication — If you include the same toEmail twice, only the first occurrence is kept
  2. Suppression filtering — Emails on your suppression list (bounces, complaints, manual suppressions, unsubscribes) are silently removed from the send but persisted as dropped recipient rows on the job, so you can audit them via email.status(jobId)
  3. 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

ErrorStatusCause
BadRequestError400Empty emails array, exceeds 500 limit, missing required fields
ForbiddenError403Domain not verified, tenant suspended
ConflictError409Idempotency key is currently being processed by another request
RateLimitError429Exceeded 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.

ts
const status = await outbound.email.status('550e8400-e29b-41d4-a716-446655440000');

Response

ts
{
  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

StatusDescriptionFinal?
queuedWaiting in the queueNo
processingBeing sent to AWS SESNo
sentSES accepted the emailNo
deliveredDelivered to recipient's mailboxYes
bouncedHard or soft bounceYes
complainedRecipient reported as spamYes
openedRecipient opened the emailNo (can become clicked)
clickedRecipient clicked a linkYes
failedFailed to send to SESYes
cancelledCancelled before sending via email.cancel()Yes
droppedRecipient 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. sentdelivered 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

ts
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

ErrorStatusCause
NotFoundError404Job ID doesn't exist or belongs to a different tenant
AuthenticationError401Invalid 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.

ts
// 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:

  1. Marks recipients as cancelled in the database — the worker checks this and skips them
  2. Removes the job from the queue if it hasn't started yet (removedFromQueue in 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:

ts
// 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:

ts
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

FieldTypeRequiredDescription
campaignIdstringOne ofCancel all jobs tagged with this campaign ID
jobIdstringOne ofCancel a single specific job

Response

ts
{
  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

ErrorStatusCause
BadRequestError400Neither campaignId nor jobId provided
NotFoundError404No jobs found for this tenant matching the criteria
ConflictError409All matching jobs are already completed, failed, or cancelled

Released under the MIT License.