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/emailInitialization
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_abc123from 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 monthOnce 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 timelineList 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:
| Capability | HTTP |
|---|---|
| Get one contact | GET /v1/contacts/{id} |
| Update / delete a campaign | PATCH, DELETE /v1/campaigns/{id} |
| Campaign link clicks | GET /v1/campaigns/{id}/links |
| Update / delete / refresh an audience | PATCH, DELETE, POST …/refresh on /v1/audiences/{id} |
| List or remove audience contacts | GET, DELETE on /v1/audiences/{id}/contacts |
| Render a markdown preview | POST /v1/templates/render-preview |
| API key management | /v1/api-keys |
| Workspace brand logo | PUT, 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.
Links
- npm: https://www.npmjs.com/package/@tratto/email
- GitHub: https://github.com/tratto-email/tratto-node
- API Docs: API Reference
Edit this page on GitHub
Last updated on