Send Email
The core endpoint for sending emails via Tratto
POST /v1/emails is the core endpoint for sending emails. Send a simple transactional email, use a template, schedule for later, or attach files.
To send up to 100 emails in one call, see Send a Batch.
Prerequisites
Before you can send emails, you need a verified sending domain. See Domains for setup.
Minimal Send
Send a basic email with just the required fields.
cURL
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome!",
"text": "Hello, this is a test email."
}'Node.js
const response = await fetch('https://api.tratto.email/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer tratto_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'hello@yourdomain.com',
to: 'user@example.com',
subject: 'Welcome!',
text: 'Hello, this is a test email.',
}),
});
const { data } = await response.json();
console.log('Email sent:', data.id);Python
import requests
response = requests.post(
'https://api.tratto.email/v1/emails',
headers={
'Authorization': 'Bearer tratto_live_...',
'Content-Type': 'application/json',
},
json={
'from': 'hello@yourdomain.com',
'to': 'user@example.com',
'subject': 'Welcome!',
'text': 'Hello, this is a test email.',
},
)
data = response.json()['data']
print(f"Email sent: {data['id']}")PHP
$tratto = new \Tratto\Client(getenv('TRATTO_API_KEY'));
$email = $tratto->emails->send([
'from' => 'hello@yourdomain.com',
'to' => 'user@example.com',
'subject' => 'Welcome!',
'text' => 'Hello, this is a test email.',
]);
echo "Email sent: {$email['id']}\n";Response:
{
"data": {
"id": "email_abc123xyz",
"status": "queued",
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome!",
"text": "Hello, this is a test email.",
"createdAt": "2025-06-30T12:00:00Z"
}
}Send with HTML
Include both text (fallback) and html (rendered content):
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome!",
"text": "Hello, this is a test email.",
"html": "<h1>Hello!</h1><p>This is a test email.</p>"
}'Email clients show html if supported; fall back to text otherwise.
Depending on your plan, Tratto may append a footer to the HTML version — the "Sent using Tratto" badge, mandatory on some plans (today Free) and optional on the others. See Email Footer & Branding.
Send with Markdown
Pass a markdown body instead of html and Tratto renders it server-side
into responsive, email-safe HTML (plus a plain-text part) using the
Markdown format:
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome!",
"markdown": "# Welcome, {{firstName}}!\n\nThanks for signing up.\n\n[Open your dashboard](https://app.yourdomain.com){button}",
"variables": {"firstName": "Alice"}
}'Rules and behavior:
markdownandhtmlare mutually exclusive — sending both fails with 422 ("markdown and html are mutually exclusive — provide one or the other.").- At least one of
html,text,markdown, ortemplateIdis required, and an emptymarkdownfails with 422 ("markdown must not be empty."). - The markdown is rendered once, at request time; the resulting HTML and text are stored on the email, so delivery retries never re-render.
- An explicit
textfield wins over the render-generated plain-text part. {{variables}}are substituted at delivery, after the render — same as every other send.- Raw HTML inside the markdown is escaped to literal text, and
javascript:/data:links are never linkified. See Markdown Templates for the syntax and the security model.
Send with a Template
Instead of composing HTML in code, use a template with variable substitution.
Step 1: Create a template (see Templates)
{
"name": "Welcome Email",
"html": "<h1>Hello {{firstName}}!</h1><p>Your code: {{code}}</p>"
}A template holds a name and a body. It has no subject — that belongs to each send.
Step 2: Send using the template
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"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. The subject and the template's html
(and, for a Markdown template, its pinned text part) are all rendered with
the variables you pass; a variable you don't pass becomes an empty string.
The template has to be published. This request does not read the template,
so it still answers 202 for a draft one, but the email is then marked
status: "failed" and never leaves. With API 0.29.0 or later
GET /v1/emails/{id} returns failureReason: "template_not_published", and
webhooks subscribed to failed receive a
failed event; before that version the
email showed failed with no explanation. Publish the template before you
send: see Templates.
Recipients: to, cc and bcc
Requires API 0.21.0 or later.
An email can have at most 50 distinct recipients across to, cc and
bcc together. Addresses are compared ignoring case, and an address that
appears more than once, in the same field or in different ones, counts once.
Over 50 the API answers 422 VALIDATION_ERROR and nothing is written or
charged:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"details": [{ "path": "/to", "message": "An email can have at most 50 recipients across to, cc and bcc (got 51). Split the send, or use a campaign for a larger audience." }]
}
}Split the send into several emails, or use a campaign for a larger audience.
Each distinct recipient counts as one email of the
monthly quota. An email with to set to
a@example.com, cc set to A@example.com and bcc set to b@example.com
counts as two emails. The check is all or nothing: if the account has fewer
emails left than the email has distinct recipients, the API answers 429 QUOTA_EXCEEDED and
nothing is sent, not even to the first recipients.
The message depends on who makes the request. The workspace owner, signed in to the dashboard, sees how many emails are left:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "This email has 3 recipients, but only 2 of the 3000 monthly emails of the free plan remain. Each distinct address in to, cc and bcc counts as one email. The quota is shared by every workspace of the account that owns this one.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Send to fewer recipients, or have the workspace owner upgrade the plan at https://app.tratto.email/settings/billing."
}
}An API key or a member gets the same error without the remaining count:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "This email has 3 recipients, more than the emails left on the free plan (3000 per month). Each distinct address in to, cc and bcc counts as one email. The quota is shared by every workspace of the account that owns this one.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Send to fewer recipients, or have the workspace owner upgrade the plan at https://app.tratto.email/settings/billing."
}
}GET /v1/emails/{id} and GET /v1/emails return to, cc and bcc
deduplicated: the first spelling of each address is kept, in the order to,
then cc, then bcc, and a cc or bcc left empty by the deduplication is
omitted.
Schedule an Email
Send an email at a future time using the scheduledAt field (ISO 8601 format):
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Reminder!",
"text": "See you tomorrow.",
"scheduledAt": "2026-12-01T09:00:00Z"
}'The email will be queued and sent at the specified time (must be in the future).
Quota of scheduled emails
A live scheduled email counts against the monthly quota
of the UTC month of its scheduledAt, not the month in which you make the
request: one email per distinct recipient (requires API 0.21.0 or later). They
are booked when the API accepts the POST. If that month has fewer
emails left than the email has distinct recipients, the request fails with 429 QUOTA_EXCEEDED and the message
names the month:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "This email has 3 recipients, more than the emails left for 2026-11 on the free plan (3000 per month). Each distinct address in to, cc and bcc counts as one email. A scheduled email counts against the UTC month of its scheduledAt. The quota is shared by every workspace of the account that owns this one.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Cancel or reschedule other emails for 2026-11, send to fewer recipients, or have the workspace owner upgrade the plan at https://app.tratto.email/settings/billing."
}
}A full current month does not stop you from scheduling into a later month that still has room. Test-mode emails do not touch the monthly quota. There is no maximum scheduling horizon.
The check happens only when the email is accepted, so emails already scheduled stay valid after a plan downgrade.
Cancel a scheduled email
DELETE /v1/emails/{id} cancels an email that is still scheduled and gives
its quota back, one email per distinct recipient, to the month it was booked on. It answers 204 with no body.
An email that is already queued or sent cannot be canceled: the API
answers 409 CONFLICT.
curl -X DELETE https://api.tratto.email/v1/emails/email_abc123xyz \
-H "Authorization: Bearer tratto_live_..."Reschedule a scheduled email
PATCH /v1/emails/{id} with a new scheduledAt, which must be in the future,
moves an email that is still scheduled. Within the same UTC month nothing
changes on the quota. Into another month the quota moves with the email: if
the destination month has fewer emails left than the email has distinct recipients, the API answers 429 QUOTA_EXCEEDED and the
email stays as it was. The body takes only scheduledAt: with API 0.24.0 or
later any other field fails with 422 VALIDATION_ERROR and the email is not
changed (see Unknown fields).
curl -X PATCH https://api.tratto.email/v1/emails/email_abc123xyz \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{"scheduledAt": "2026-12-01T09:00:00Z"}'{ "data": { "id": "email_abc123xyz", "scheduledAt": "2026-12-01T09:00:00.000Z" } }DELETE and PATCH can also answer 429 RATE_LIMITED with a Retry-After
header, when other requests are updating the account's counter at the same
moment. Repeating the request after that delay is safe.
The SDKs have no method to cancel or reschedule an email: call the API over HTTP as shown above.
Attachments
Requires API 0.29.0 or later, and a Starter, Growth or Scale plan.
Upload each file first with POST /v1/attachments, then pass the ids it
returns in attachments:
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "billing@yourdomain.com",
"to": "user@example.com",
"subject": "Your invoice",
"text": "Your invoice is attached.",
"attachments": [{ "id": "att_V1StGXR8Z5jdHi6BmyT0a" }]
}'The email, its html and text and its files together can be at most 25 MB
(25,000,000 bytes), with no limit on the number of files. An unknown id is
404 NOT_FOUND; an id listed twice, a file of the other mode (live or test),
one that expires less than an hour after the send, or an email over 25 MB is
422 VALIDATION_ERROR; on Free the send is 403 PLAN_UPGRADE_REQUIRED.
Nothing is written or charged in any of these cases.
How to upload, the size limits, the forbidden file types, retention and storage are in Attachments.
Tags for Organization
Add tags to organize and filter emails:
curl -X POST https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Order Confirmation",
"text": "Your order has been confirmed.",
"tags": ["order", "transactional"]
}'Later, filter emails by tags or use them in webhooks.
Full Request Body Reference
| Field | Type | Required | Description |
|---|---|---|---|
from | string | Sender, as Name <email> or a bare address. With a live key its domain must be verified. If omitted, the workspace transactional sender is used, or the general default sender; with neither, 422. See Senders | |
to | string | string[] | ✓ | Recipient address, or an array of addresses. to, cc and bcc together: at most 50 distinct recipients, see Recipients |
cc | string[] | Carbon copy recipients | |
bcc | string[] | Blind carbon copy recipients | |
subject | string | ✓ | Email subject line (required with a template too, max 998 chars) |
text | string | Plain text content (used as fallback) | |
html | string | HTML content (mutually exclusive with markdown) | |
markdown | string | Markdown, rendered server-side (mutually exclusive with html, max 100,000 chars) | |
templateId | string | Template ID (instead of text/html/markdown) | |
variables | object | Variables to interpolate in template | |
scheduledAt | string (ISO 8601) | Schedule email for future delivery | |
tags | string[] | Organization tags | |
replyTo | string | Reply-to address. If omitted on a request without from, the replyTo of the transactional sender applies | |
headers | object | Custom email headers (advanced) | |
attachments | object[] | Files uploaded with POST /v1/attachments, as [{ "id": "att_..." }] (requires API 0.29.0 or later). See Attachments |
Unknown fields
With API 0.24.0 or later, a field that is not in this table fails with
422 VALIDATION_ERROR and nothing is sent. Names are case-sensitive and
camelCase, so reply_to or scheduled_at are unknown fields. The entry in
details names the unknown field, or fields, in message; for fields at the
top level of the body, path is / and there is one entry for all of them
(Unrecognized keys: "reply_to", "foo"):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"details": [
{ "path": "/", "message": "Unrecognized key: \"reply_to\"" }
]
}
}Earlier versions dropped unknown fields and accepted the email with 202, so
a misspelt replyTo went out without its reply-to address. The same check
applies to PATCH /v1/emails/{id} and has
always applied to batches.
Response
Success (202 Accepted): the email is accepted and queued. livemode is
true with a live key and false with a test key. To read the status and the
other fields, call GET /v1/emails/{id}.
{
"data": {
"id": "email_abc123xyz",
"livemode": true
}
}Error (4xx):
{
"error": {
"code": "FORBIDDEN",
"message": "Domain 'mail.acme.com' is not verified for this tenant.",
"docs": "https://docs.tratto.email/en/domains",
"suggestion": "Add and verify the domain at https://app.tratto.email/domains before sending."
}
}Common Errors
FORBIDDEN: unverified domain
Cause: The from domain has not been verified yet. The API returns 403 with
the domain named in message.
Fix: Complete the domain verification process.
QUOTA_EXCEEDED
Cause: The account has fewer emails left this month than the email
has distinct recipients, or, with scheduledAt, in the month it is scheduled for. Nothing was
sent.
Fix: Send to fewer recipients, wait for the next UTC month or upgrade the plan. Retrying the same request will not help. See Recipients.
RATE_LIMITED
Cause: More than 100 requests in one second with the same API key, or more than 500 requests in one minute from the same IP.
Fix: Implement exponential backoff and retry later. See Rate Limits.
SERVICE_UNAVAILABLE
Cause: Tratto could not put the email in its delivery queue (the queue failed
or did not answer within 10 seconds). The API returns 503 with a Retry-After
header and the message Nothing was sent and nothing was charged. Retry the request; the same Idempotency-Key can be reused.
No email was created and no quota was used, so retrying cannot send twice.
With API 0.21.0 or later the same 503, with Retry-After: 1 and the same
message, also covers an email that could not be stored: the quota is given
back. If giving the quota back fails as well, the message is
Nothing was sent. This request may still count against the monthly quota. Retry the request; the same Idempotency-Key can be reused. Nothing was sent in this case either. If the store reports an
error but the email was in fact written, the request goes on and answers
202.
Fix: Retry after Retry-After seconds. If you sent an Idempotency-Key,
reuse the same key. See Idempotency.
VALIDATION_ERROR
Cause: Invalid request (missing required field, malformed JSON, more than
50 distinct recipients across to, cc and bcc, an
unknown field with API 0.24.0 or later, an invalid
attachment, etc.).
Fix: Check the error message and refer to the Error Codes reference.
Next Steps
- Many emails at once? See Send a Batch
- Track email events? Set up Webhooks
- Check email status? See Email Status & Lifecycle
- Create templates? Go to Templates
- Manage domains? Read Domains
Edit this page on GitHub
Last updated on