Skip to content

Templates ​

Create reusable email templates with placeholders. Templates are synced to AWS SES for optimized bulk sending.

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.

Create Template ​

ts
const { template } = await outbound.templates.create({
  name: 'welcome-email',
  subject: 'Welcome {{firstName}}!',
  htmlBody: `
    <h1>Hello {{firstName}}</h1>
    <p>Welcome to {{company}}. We're glad to have you.</p>
    <p>Click <a href="{{dashboardUrl}}">here</a> to get started.</p>
  `,
  textBody: 'Hello {{firstName}}, Welcome to {{company}}.',
  variables: ['firstName', 'company', 'dashboardUrl'],
  metadata: { category: 'onboarding', version: '2' },
});

console.log(template.id);               // 'uuid'
console.log(template.ses_template_name); // 'outbound-{uuid}' (auto-synced to SES)

Parameters ​

FieldTypeRequiredDescription
namestringYesUnique name within your tenant
subjectstringYesSubject line — supports
htmlBodystringYesHTML body — supports
textBodystringNoPlain text alternative
variablesstring[]NoList of variable names used in the template
metadataobjectNoCustom key-value pairs

Response ​

ts
{
  message: 'Template created',
  template: {
    id: '550e8400-e29b-41d4-a716-446655440000',
    tenant_id: 'uuid',
    name: 'welcome-email',
    subject: 'Welcome {{firstName}}!',
    html_body: '<h1>Hello {{firstName}}</h1>...',
    text_body: 'Hello {{firstName}}...',
    variables: ['firstName', 'company', 'dashboardUrl'],
    use_count: 0,
    last_sent_at: null,
    ses_template_name: 'outbound-550e8400...',
    status: 'active',
    metadata: { category: 'onboarding', version: '2' },
    created_at: '2026-03-08T10:00:00.000Z',
    updated_at: '2026-03-08T10:00:00.000Z'
  }
}

Possible Errors ​

ErrorStatusCause
BadRequestError400Missing name, subject, or htmlBody
ConflictError409Template with this name already exists for your tenant
ForbiddenError403Template quota exceeded (check outbound.templates.stats())

List Templates ​

ts
const result = await outbound.templates.list({
  status: 'active',
  search: 'welcome',
  sortBy: 'use_count',
  sortOrder: 'DESC',
  page: 1,
  limit: 20,
});

console.log(result.total);           // 45
console.log(result.quota.remaining); // 955 templates remaining

Query Parameters ​

FieldTypeDefaultDescription
status'active' | 'archived'AllFilter by template status
searchstring—Search by template name (case-insensitive)
sortBystringcreatedAtSort by: name, createdAt, use_count, last_sent_at
sortOrder'ASC' | 'DESC'DESCSort direction
pagenumber1Page number
limitnumber20Items per page (max 100)

Response ​

ts
{
  templates: [ /* array of Template objects */ ],
  total: 45,
  page: 1,
  limit: 20,
  quota: {
    used: 45,
    allocated: 1000,
    remaining: 955
  }
}

Auto-Pagination ​

Iterate through all templates without managing pages manually:

ts
for await (const template of outbound.templates.listAll({ status: 'active' })) {
  console.log(`${template.name} — used ${template.use_count} times`);
}

Get Single Template ​

ts
const { template } = await outbound.templates.get('template-uuid');

Possible Errors ​

ErrorStatusCause
NotFoundError404Template ID doesn't exist or belongs to another tenant

Update Template ​

Only pass the fields you want to change:

ts
const { template } = await outbound.templates.update('template-uuid', {
  subject: 'Updated: Welcome {{firstName}}!',
  htmlBody: '<h1>New design</h1><p>Hello {{firstName}}</p>',
  variables: ['firstName'],
});

Parameters ​

FieldTypeDescription
namestringRename the template
subjectstringNew subject line
htmlBodystringNew HTML body
textBodystringNew text body
variablesstring[]Updated variable list
metadataobjectUpdated metadata
status'active' | 'archived'Archive or reactivate

Archive a Template ​

ts
await outbound.templates.update('template-uuid', { status: 'archived' });

Archived templates cannot be used for sending. Reactivate with { status: 'active' }.

SES Sync

When you update subject, htmlBody, or textBody, the template is automatically re-synced to AWS SES. No manual action needed.


Delete Template ​

ts
const result = await outbound.templates.delete('template-uuid');
// { message: 'Template deleted', id: 'template-uuid' }

This also deletes the template from AWS SES. This action is irreversible.

Possible Errors ​

ErrorStatusCause
NotFoundError404Template doesn't exist

Duplicate Template ​

Create a copy of an existing template:

ts
// With auto-generated name ("Copy of welcome-email")
const { template } = await outbound.templates.duplicate('template-uuid');

// With custom name
const { template } = await outbound.templates.duplicate('template-uuid', {
  name: 'welcome-email-v2',
});

The duplicate is a completely independent template with its own ID, use_count: 0, and a new SES template. It counts against your template quota.

Possible Errors ​

ErrorStatusCause
NotFoundError404Source template doesn't exist
ForbiddenError403Template quota exceeded
ConflictError409Name already exists

Preview Template ​

Render a template with sample variables without sending — useful for testing in your UI:

ts
const preview = await outbound.templates.preview('template-uuid', {
  variables: { firstName: 'John', company: 'Acme' },
});

console.log(preview.subject);            // "Welcome John!"
console.log(preview.htmlBody);           // "<h1>Hello John</h1><p>Welcome to Acme...</p>"
console.log(preview.missingVariables);   // [] — or ['dashboardUrl'] if not provided

Parameters ​

FieldTypeRequiredDescription
variablesRecord<string, string>NoVariable values to substitute

Response ​

ts
{
  subject: 'Welcome John!',
  htmlBody: '<h1>Hello John</h1><p>Welcome to Acme.</p>',
  textBody: 'Hello John, Welcome to Acme.',
  missingVariables: ['dashboardUrl']   // variables in template but not provided
}

TIP

Use missingVariables to validate that your code is passing all required variables before sending.


Send Email with Template ​

Single Recipient ​

ts
const { jobId, messageId } = await outbound.templates.send({
  templateId: 'template-uuid',
  toEmail: 'user@example.com',
  fromEmail: 'noreply@yourcompany.com',
  senderName: 'YourCompany',
  replyTo: 'support@yourcompany.com',
  variables: {
    firstName: 'John',
    company: 'Acme',
    dashboardUrl: 'https://app.acme.com/dashboard',
  },
  priority: 'high',
  idempotencyKey: 'welcome-john-123',
  metadata: { userId: 'u_123' },
  tracking: { opens: true, clicks: true },
});

Parameters ​

FieldTypeRequiredDescription
templateIdstringYesTemplate ID to use
toEmailstringYesRecipient email
fromEmailstringYesSender email (verified domain)
variablesRecord<string, string>NoTemplate variable values
senderNamestringNoDisplay name
replyTostringNoReply-to address
ccEmailstringNoCC recipient
bccEmailstringNoBCC recipient
priority'critical' | 'urgent' | 'high' | 'normal' | 'low' | 'deferred' | 'backlog'NoDefault: normal
idempotencyKeystringNoPrevents duplicate sends — expires after 24 hours
campaignIdstringNoTag this send as part of a campaign — enables bulk cancellation via email.cancel()
metadataobjectNoCustom metadata
headersobjectNoCustom email headers
trackingobjectNoOpen/click tracking config

Response ​

ts
{
  message: 'Email queued successfully',
  jobId: 'uuid',
  messageId: 'uuid'
}

Possible Errors ​

ErrorStatusCause
BadRequestError400Missing templateId, toEmail, or fromEmail
NotFoundError404Template doesn't exist
ForbiddenError403Template is archived, domain not verified
RateLimitError429Per-second rate limit exceeded, or the global AWS SES daily sending quota is exhausted ("Daily Quota Exhausted (X/Y). Try again later.")

Idempotency for Template Sends ​

Idempotency works identically for template and non-template sends: keys expire after 24 hours, in-flight duplicates return 409, and completed duplicates return the exact cached response.

ts
import { createHash } from 'crypto';

// Single template send: combine template + purpose + recipient
const key = createHash('sha256')
  .update(`welcome-email:${userId}:${userEmail}`)
  .digest('hex');

await outbound.templates.send({
  templateId: 'template-uuid',
  toEmail: userEmail,
  fromEmail: 'noreply@company.com',
  variables: { firstName: 'Alice' },
  idempotencyKey: key,
});

// Bulk template send: one key for the entire batch
const bulkKey = createHash('sha256')
  .update(`march-newsletter:batch-1`)
  .digest('hex');

await outbound.templates.bulkSend({
  templateId: 'newsletter-template',
  fromEmail: 'news@company.com',
  idempotencyKey: bulkKey,
  recipients: [
    { toEmail: 'alice@example.com', variables: { firstName: 'Alice' } },
    { toEmail: 'bob@example.com', variables: { firstName: 'Bob' } },
  ],
});

TIP

There is no difference in how idempotency keys work between template and non-template sends. Keys expire after 24 hours. The same rules apply — see the Email Idempotency guide for full details.


Bulk Send with Template ​

Send the same template to up to 500 recipients with per-recipient variables. This uses AWS SES SendBulkEmailCommand for maximum throughput.

ts
const result = await outbound.templates.bulkSend({
  templateId: 'template-uuid',
  fromEmail: 'noreply@yourcompany.com',
  senderName: 'YourCompany',
  replyTo: 'support@yourcompany.com',
  idempotencyKey: 'march-newsletter-2026',
  recipients: [
    {
      toEmail: 'alice@example.com',
      variables: { firstName: 'Alice', company: 'Acme' },
    },
    {
      toEmail: 'bob@example.com',
      variables: { firstName: 'Bob', company: 'Acme' },
      ccEmail: 'bob-assistant@example.com',
    },
    {
      toEmail: 'charlie@example.com',
      variables: { firstName: 'Charlie', company: 'Acme' },
      metadata: { segment: 'enterprise' },
    },
  ],
});

Parameters ​

FieldTypeRequiredDescription
templateIdstringYesTemplate ID
fromEmailstringYesSender email (verified domain)
recipientsTemplateBulkRecipient[]YesArray of recipients (max 500)
senderNamestringNoDisplay name
replyTostringNoReply-to address
priority'critical' | 'urgent' | 'high' | 'normal' | 'low' | 'deferred' | 'backlog'NoQueue priority for the whole batch (default normal), draining highest-urgency first in that order. critical/urgent/high bypass send pacing; normal and below are paced. Use backlog/deferred for large backfill campaigns so transactional sends jump ahead.
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
variablesRecord<string, string>NoPer-recipient variable values
ccEmailstringNoCC for this recipient
bccEmailstringNoBCC for this recipient
metadataobjectNoPer-recipient metadata

Response ​

ts
{
  message: 'Bulk email queued successfully',
  jobId: 'uuid',
  recipientCount: 487,
  suppressedCount: 10,
  suppressedEmails: ['bounced@example.com', 'spam@example.com'],  // absent on duplicate responses
  duplicatesRemoved: 3,
  usingSesTemplate: true   // confirms SES bulk template optimization is active
}

suppressedEmails on duplicate responses

When an idempotencyKey is replayed, the response returns duplicate: true but omits suppressedEmails — only the count is cached. Always default the field:

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

Possible Errors ​

ErrorStatusCause
BadRequestError400Empty recipients, exceeds 500 limit
NotFoundError404Template doesn't exist
ForbiddenError403Template archived, domain not verified
ConflictError409Idempotency key is currently being processed by another request
RateLimitError429Per-second rate limit exceeded, or the global AWS SES daily sending quota is exhausted ("Daily Quota Exhausted (X/Y). Try again later.")

All-suppressed batches still succeed

If every recipient is on the suppression list, the request still returns 202 Accepted with a jobId. recipientCount is 0, and each suppressed address is persisted as a dropped recipient row — query email.status(jobId) to audit them.

SES Template Optimization

When using bulkSend, the platform uses AWS SES's SendBulkEmailCommand which sends up to 50 recipients per API call instead of one-at-a-time. This is significantly faster for large batches. The usingSesTemplate: true field in the response confirms this optimization is active.


Template Statistics ​

Get an overview of your template usage:

ts
const stats = await outbound.templates.stats();

Response ​

ts
{
  quota: {
    used: 45,
    allocated: 1000,
    remaining: 955
  },
  counts: {
    total: 45,
    active: 43,
    archived: 2,
    neverUsed: 5
  },
  totalEmailsSentViaTemplates: 150000,
  mostUsed: [
    { id: 'uuid', name: 'welcome-email', use_count: 50000, ... },
    // top 5 by use_count
  ],
  leastUsed: [
    { id: 'uuid', name: 'old-promo', use_count: 2, ... },
    // bottom 5 by use_count
  ],
  recentlyUsed: [
    { id: 'uuid', name: 'march-newsletter', last_sent_at: '2026-03-08T...', ... },
    // 5 most recently sent
  ]
}

Complete Example: Template Lifecycle ​

ts
import { Outbound, NotFoundError } from '@masters-union/outbound-sdk';

const outbound = new Outbound({ apiKey: 'mu_outbound_...' });

// 1. Create
const { template } = await outbound.templates.create({
  name: 'order-confirmation',
  subject: 'Order #{{orderId}} Confirmed',
  htmlBody: `
    <h1>Thank you, {{customerName}}!</h1>
    <p>Your order <strong>#{{orderId}}</strong> has been confirmed.</p>
    <p>Total: {{orderTotal}}</p>
    <p><a href="{{trackingUrl}}">Track your order</a></p>
  `,
  variables: ['customerName', 'orderId', 'orderTotal', 'trackingUrl'],
});

// 2. Preview before sending
const preview = await outbound.templates.preview(template.id, {
  variables: {
    customerName: 'Jane',
    orderId: '1234',
    orderTotal: '$99.99',
    trackingUrl: 'https://track.example.com/1234',
  },
});
console.log('Preview subject:', preview.subject);
// "Order #1234 Confirmed"

// 3. Send to one customer
await outbound.templates.send({
  templateId: template.id,
  toEmail: 'jane@example.com',
  fromEmail: 'orders@yourcompany.com',
  senderName: 'YourCompany Orders',
  variables: {
    customerName: 'Jane',
    orderId: '1234',
    orderTotal: '$99.99',
    trackingUrl: 'https://track.example.com/1234',
  },
});

// 4. Bulk send to many customers
const bulkResult = await outbound.templates.bulkSend({
  templateId: template.id,
  fromEmail: 'orders@yourcompany.com',
  senderName: 'YourCompany Orders',
  recipients: orders.map(order => ({
    toEmail: order.customerEmail,
    variables: {
      customerName: order.customerName,
      orderId: order.id,
      orderTotal: order.total,
      trackingUrl: `https://track.example.com/${order.id}`,
    },
  })),
});

console.log(`Sent to ${bulkResult.recipientCount} customers`);

// 5. Check stats
const stats = await outbound.templates.stats();
console.log(`Template quota: ${stats.quota.used}/${stats.quota.allocated}`);

// 6. Archive when no longer needed
await outbound.templates.update(template.id, { status: 'archived' });

Released under the MIT License.