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:

  • markdown and html are 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, or templateId is required, and an empty markdown fails 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 text field 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

FieldTypeRequiredDescription
fromstringSender, 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
tostring | string[]✓Recipient address, or an array of addresses. to, cc and bcc together: at most 50 distinct recipients, see Recipients
ccstring[]Carbon copy recipients
bccstring[]Blind carbon copy recipients
subjectstring✓Email subject line (required with a template too, max 998 chars)
textstringPlain text content (used as fallback)
htmlstringHTML content (mutually exclusive with markdown)
markdownstringMarkdown, rendered server-side (mutually exclusive with html, max 100,000 chars)
templateIdstringTemplate ID (instead of text/html/markdown)
variablesobjectVariables to interpolate in template
scheduledAtstring (ISO 8601)Schedule email for future delivery
tagsstring[]Organization tags
replyTostringReply-to address. If omitted on a request without from, the replyTo of the transactional sender applies
headersobjectCustom email headers (advanced)
attachmentsobject[]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


Edit this page on GitHub

Last updated on