Node.js / TypeScript SDK

Official Node.js SDK for the Tratto Email API

The official SDK for Node.js and TypeScript: @tratto/email

Every example on this page typechecks against the published 1.8.0. Where a feature needs a particular version, the page names the minimum one that has it; it never names the newest, because that goes stale on its own.

Installation

npm install @tratto/email

Initialization

The exported class is Tratto, and the API key is a positional argument, not an options object:

import { Tratto } from '@tratto/email';

const tratto = new Tratto(process.env.TRATTO_API_KEY!);

An optional second argument overrides the base URL:

const tratto = new Tratto(process.env.TRATTO_API_KEY!, {
  baseUrl: 'https://api.tratto.email',
});

timeout sets how long each request may take, in milliseconds, and requires 1.8.0 or newer. The default is 60000 (60 seconds). A request that runs longer throws TrattoError with code: 'timeout' and statusCode: 0:

const tratto = new Tratto(process.env.TRATTO_API_KEY!, { timeout: 10_000 });

A send that timed out may still have reached the API. Retry it with the same idempotency key, so it cannot go out twice.

From 1.8.0 the SDK percent-encodes every id it puts in a URL path, so an id containing /, ? or # cannot change the route called.

Send Email

emails.send resolves to { id } — nothing else. To read the status, fetch the email back.

const { id } = await tratto.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Hello!',
  text: 'Welcome to Tratto',
  html: '<h1>Welcome!</h1>',
});

console.log(id); // email_abc123

from can be omitted from 1.5.0 or newer: Tratto then uses the workspace transactional sender, or the general default sender if there is none. See Senders.

Pass an idempotency key as the second argument:

await tratto.emails.send(
  { from: 'hello@yourdomain.com', to: 'user@example.com', subject: 'Hi', text: 'Hi' },
  'order-4711-confirmation',
);

Send with Template

const { id } = await tratto.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Welcome, Alice!',
  templateId: 'tmpl_abc123',
  variables: {
    firstName: 'Alice',
    code: 'SECRET123',
  },
});

subject is required on every send, template or not. A template carries no subject of its own — see Templates.

Send with Markdown

Send Markdown and let the server render responsive email HTML — markdown is mutually exclusive with html:

const { id } = await tratto.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Welcome!',
  markdown: '# Welcome, {{firstName}}!',
  variables: { firstName: 'Alice' },
});

templates.create and templates.update take markdown too, and the Template response type carries format, source and renderWarnings.

Schedule Email

const { id } = await tratto.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Reminder',
  text: 'See you tomorrow!',
  scheduledAt: new Date('2026-10-01T09:00:00Z'),
});

While the email is still scheduled you can cancel it or move it to another date. Both methods require 1.8.0 or newer:

await tratto.emails.reschedule('email_abc123', new Date('2026-10-02T09:00:00Z'));
await tratto.emails.cancel('email_abc123'); // its quota goes back to the month

Once the email has left scheduled, both answer 409 CONFLICT. Moving it to another UTC month can answer 429 QUOTA_EXCEEDED when that month is full, and 429 RATE_LIMITED is safe to retry.

Send a Batch

emails.sendBatch() sends up to 100 emails in one request and requires 1.6.0 or newer. Each email takes the fields of emails.send() except scheduledAt; the optional second argument is the idempotency key, which covers the whole batch.

import { randomUUID } from 'node:crypto';

const batch = await tratto.emails.sendBatch(
  [
    { from: 'hello@yourdomain.com', to: 'alice@example.com', subject: 'Your receipt', text: 'Thanks, Alice.' },
    { from: 'hello@yourdomain.com', to: 'bob@example.com', subject: 'Your receipt', text: 'Thanks, Bob.' },
  ],
  randomUUID(),
);

console.log(`${batch.accepted} queued, ${batch.failed} failed`);
for (const e of batch.emails) {
  if (e.status === 'failed') console.error(e.to, e.error.code, e.error.message);
}

The call resolves on every 202, also when some emails failed: a failed email has no id. Send only those again, in a new batch with a new key. After a 503 or a 500 nothing was sent: repeat the same call with the same key. Quota, limits and errors are in Send a Batch.

Attachments

Requires @tratto/email 1.7.0 or newer, and a paid plan: the Free plan answers 403 PLAN_UPGRADE_REQUIRED. Upload the file first, then reference its id from emails.send() or emails.sendBatch().

import { readFile } from 'node:fs/promises';

const file = await tratto.attachments.upload(await readFile('invoice.pdf'), {
  filename: 'invoice.pdf',
  contentType: 'application/pdf',
});

await tratto.emails.send({
  from: 'billing@yourdomain.com',
  to: 'alice@example.com',
  subject: 'Your invoice',
  html: '<p>Attached.</p>',
  attachments: [{ id: file.id }],
});

const meta = await tratto.attachments.get(file.id); // metadata only, never the bytes
await tratto.attachments.delete(file.id);

The content can be a Buffer, Uint8Array, ArrayBuffer or Blob. Limits, expiry and storage are in Attachments.

Get Email Status

const email = await tratto.emails.get('email_abc123');
console.log(email.status); // 'delivered'
console.log(email.events); // full timeline

List Emails

const emails = await tratto.emails.list({
  limit: 50,
  after: 'cursor_value', // for pagination
});

emails.data.forEach((email) => {
  console.log(`${email.id}: ${email.status}`);
});

Add a Domain

The method is add, and it takes the domain as a string:

const domain = await tratto.domains.add('hello.yourdomain.com');

// `records` is an array of DNS records, not an object keyed by type
for (const record of domain.records) {
  console.log(`${record.type} ${record.host} → ${record.value}`);
}

Verify Domain

const verified = await tratto.domains.verify('dom_abc123');
console.log(verified.status); // 'pending' | 'verified' | 'failed'

Create Contact

Custom properties live in customFields. There is no metadata field:

const { id } = await tratto.contacts.create({
  email: 'user@example.com',
  firstName: 'Alice',
  lastName: 'Smith',
  tags: ['vip', 'newsletter'],
  customFields: { plan: 'pro' },
});

Create Campaign

CreateCampaignParams still requires templateId, audienceId, fromName, fromEmail and subjectA. create resolves to { id }:

const { id } = await tratto.campaigns.create({
  name: 'Q3 Sale',
  templateId: 'tmpl_abc123',
  audienceId: 'aud_abc123',
  fromName: 'Acme',
  fromEmail: 'hello@yourdomain.com',
  subjectA: 'Our summer sale starts today',
  subjectB: 'Summer sale: 30% off, today only', // optional A/B subject
});

const campaign = await tratto.campaigns.get(id);
console.log(campaign.status); // 'draft'

The API itself is more permissive than the 1.8.0 types: it accepts inline html instead of a templateId, and treats an omitted audienceId as "every contact in the workspace". Neither is expressible through the SDK yet. Use HTTP for those.

Send Campaign

const sent = await tratto.campaigns.send('camp_xyz789');
console.log(sent.status); // 'sending'

Schedule it instead by passing a date:

await tratto.campaigns.send('camp_xyz789', {
  scheduledAt: new Date('2026-10-01T09:00:00Z'),
});

Stop a Campaign

pause holds a campaign that is sending or waiting. unschedule cancels a scheduled send and puts the campaign back to draft; it needs 1.2.0 or newer:

await tratto.campaigns.pause('camp_xyz789');
await tratto.campaigns.unschedule('camp_xyz789');

unschedule only applies while the status is scheduled and nothing has left. On a campaign that is sending, already paused, or waiting out its probe wave it answers 409 CONFLICT, because part of the list may already have the email. Pause it instead.

Campaign.pausedReason says why a campaign stopped on its own, and also needs 1.2.0 or newer. It reads 'quota_exceeded' | 'schedule_missed' | 'bounce_rate' | 'quota_unavailable' | null. 'bounce_rate' is the automatic bounce stop. 'quota_unavailable' means the monthly quota could not be read because of an error on our side, not that it is exhausted, so the campaign can be resumed; the type includes it from 1.8.0 or newer. All four are explained in Why a campaign is paused.

const campaign = await tratto.campaigns.get('camp_xyz789');
if (campaign.pausedReason === 'bounce_rate') {
  // Clean the list before calling send again.
}

Get Campaign Stats

The method is getStats. Rates are percentages: 50 means 50%, not 0.5 — the same scale as Analytics.

const stats = await tratto.campaigns.getStats('camp_xyz789');

console.log(stats.stats.delivered);   // 4950
console.log(stats.rates.openRate);    // 50 → 50%
console.log(stats.rates.deliveryRate) // 99 → 99%

An alert written as rates.deliveryRate < 0.95 will never fire. The comparison you want is < 95.

Register Webhook

const webhook = await tratto.webhooks.create({
  url: 'https://yourapi.com/webhooks/tratto',
  events: ['sent', 'delivered', 'opened', 'clicked', 'bounced'],
});

console.log(webhook.secret); // whsec_xyz789...

Verify Webhooks

verifyWebhook checks the X-Tratto-Signature header of a delivery and requires 1.8.0 or newer. Pass the raw body exactly as received, before any JSON parsing:

import { verifyWebhook } from '@tratto/email';

export async function POST(request: Request) {
  const rawBody = await request.text(); // read once, before parsing

  const ok = verifyWebhook(
    rawBody,
    request.headers.get('x-tratto-signature'),
    process.env.TRATTO_WEBHOOK_SECRET!,
  );
  if (!ok) return new Response('Invalid signature', { status: 401 });

  const event = JSON.parse(rawBody);
  console.log(event.type);
  return Response.json({ ok: true });
}

The body can also be a Buffer or Uint8Array, for example from express.raw({ type: 'application/json' }).

It returns false for a missing, malformed, stale or non-matching header, and throws only on an empty secret or a negative tolerance. The tolerance is 5 minutes either way; change it with { toleranceSeconds: 600 } as the fourth argument. How the signature is built is in Webhook Signature Verification.

Analytics

AnalyticsPeriod is '7d' | '30d' | '90d' | '180d' | '1y' (the last two require 1.1.1 or newer):

const summary = await tratto.analytics.getSummary('30d');
console.log(summary.delivered);

180d and 1y read the long-term aggregate, which only holds live data: the API rejects them for a test-mode key.

Workspace Limits

workspace.get() returns the caps of the workspace plan in limits. The Workspace.limits type (WorkspaceLimits) requires 1.8.0 or newer:

const workspace = await tratto.workspace.get();
const { emailsPerMonth, domains, webhooks } = workspace.limits;
console.log(emailsPerMonth ?? 'no limit', domains ?? 'no limit', webhooks);

The seven fields are domains, emailsPerMonth, membersPerAccount, membersPerWorkspace, workspaces, webhooks and testSendsPerDay. domains and emailsPerMonth are null when the plan has no limit.

Error Handling

TrattoError carries code, statusCode, docs, details and suggestion:

import { Tratto, TrattoError } from '@tratto/email';

const tratto = new Tratto(process.env.TRATTO_API_KEY!);

try {
  await tratto.emails.send({
    from: 'hello@unverified.com', // not verified
    to: 'user@example.com',
    subject: 'Hello',
    text: 'Test',
  });
} catch (error) {
  if (error instanceof TrattoError) {
    // The message names the domain that is not verified.
    console.error(error.code, error.statusCode, error.message);
  }
}

See Error Codes for the full list.

On a 422 VALIDATION_ERROR, details says which field is wrong, for example [{ path: '/emails/3/to', message: '...' }] for the fourth email of a batch. It requires 1.6.0 or newer and is undefined when the API sends none.

suggestion is the API's own advice on what to do next, such as waiting and retrying or fixing a setting. It requires 1.8.0 or newer and is undefined when the error carries none. It is the field to read where one code covers several situations, like the CONFLICT on campaigns.send:

try {
  await tratto.campaigns.send('camp_xyz789');
} catch (error) {
  if (error instanceof TrattoError) {
    console.error(error.code, error.suggestion ?? error.message);
  }
}

An error with code: 'timeout' and statusCode: 0 is the client timeout, not an API response.

What the SDK does not cover yet

These endpoints exist in the API and have no SDK method in 1.8.0. Call them over HTTP until the SDK catches up:

CapabilityHTTP
Get one contactGET /v1/contacts/{id}
Update / delete a campaignPATCH, DELETE /v1/campaigns/{id}
Campaign link clicksGET /v1/campaigns/{id}/links
Update / delete / refresh an audiencePATCH, DELETE, POST …/refresh on /v1/audiences/{id}
List or remove audience contactsGET, DELETE on /v1/audiences/{id}/contacts
Render a markdown previewPOST /v1/templates/render-preview
API key management/v1/api-keys
Workspace brand logoPUT, DELETE /v1/workspace/brand/logo

Some newer response fields are likewise absent from the 1.8.0 types: Workspace has no brand, Contact has no trackingOptOut, and CampaignStats has no skipped, untracked or variants. The API returns them; the types do not describe them yet.

Checked against the published @tratto/email 1.8.0 on 2026-10-27: the eight rows are still uncovered, emails.cancel and emails.reschedule are covered from 1.8.0, and ListContactsParams.q is covered from 1.4.0 (contacts.list({ q })). A row here is a promise that the gap is still real, so it is worth rereading at every SDK release.



Edit this page on GitHub

Last updated on