Send email

POST /v1/emails takes a JSON body and returns 201 with the new email's id. The email is queued immediately and sent within seconds; its status and events are available at GET /v1/emails/:id.

A full request

curl -X POST https://api.avelto.dev/v1/emails \  -H "Authorization: Bearer av_live_..." \  -H "Idempotency-Key: receipt-1042" \  -H "Content-Type: application/json" \  -d '{    "from": "Acme <billing@mail.acme.com>",    "to": [      "jane@example.com",      "Finance <finance@example.com>"    ],    "cc": "ops@example.com",    "reply_to": "support@acme.com",    "subject": "Receipt #1042",    "html": "<p>Thanks for your order.</p>",    "text": "Thanks for your order.",    "headers": {      "X-Entity-Ref-ID": "1042"    },    "tags": [      "receipt",      "order:1042"    ],    "attachments": [      {        "filename": "receipt-1042.pdf",        "content": "JVBERi0xLjQKJ...",        "content_type": "application/pdf"      }    ]  }'

The values above are written out so you can see every field. In your application they come from your own data: who the customer is, what they ordered, what the receipt number is. Every field is just a value in the request, so build it however your language builds strings.

Your own data in each email

Most emails are the same message with different details: the same receipt, a different customer and order number. There are two ways to do that, and you can mix them.

Build the fields in your code. The recipient, subject and body are ordinary strings, so put your data into them when you send. Use the order or receipt number in the idempotency key too, so a retry can never send the same receipt twice.

TypeScript
const order = await db.orders.find(orderId); // your data// escapeHtml: your framework's HTML escaping, for anything a customer typedawait avelto.emails.send(  {    from: "Acme <billing@mail.acme.com>",    to: order.customer.email,    subject: `Receipt #${order.number}`,    html: `<p>Hi ${escapeHtml(order.customer.firstName)}, thanks for your order of ${order.total}.</p>`,    text: `Hi ${order.customer.firstName}, thanks for your order of ${order.total}.`,    tags: ["receipt", `order:${order.number}`],  },  { idempotencyKey: `receipt-${order.number}` },);
Python
import htmlorder = db.orders.find(order_id)  # your dataavelto.emails.send(    from_="Acme <billing@mail.acme.com>",    to=order.customer.email,    subject=f"Receipt #{order.number}",    html=f"<p>Hi {html.escape(order.customer.first_name)}, thanks for your order of {order.total}.</p>",    text=f"Hi {order.customer.first_name}, thanks for your order of {order.total}.",    tags=["receipt", f"order:{order.number}"],    idempotency_key=f"receipt-{order.number}",)

Escape anything a customer typed (a name, a delivery note) before you put it into html, the same as you would on a web page. The subject and the plain text need no escaping, but line breaks in a subject are rejected.

Or store the email as a template. Write the subject and body once, in the dashboard or through the API, with {{variables}} where the details go, then send just the values. The template escapes them for you in the HTML, and a send that leaves a variable out is refused rather than going out with a gap in it.

TypeScript
// Template "receipt": subject "Receipt #{{order_number}}", body "<p>Hi {{first_name}}, ...</p>"await avelto.emails.send({  from: "Acme <billing@mail.acme.com>",  to: order.customer.email,  template_slug: "receipt",  variables: { order_number: order.number, first_name: order.customer.firstName, total: order.total },});

Templates suit emails that someone other than a developer might want to reword, and keep your code down to the data. Templates has the full language: escaping, variables in the subject, and what happens to a missing one.

Fields

FieldTypeNotes
fromstringRequired. a@b.com or Name <a@b.com>. Put a name containing , ; : @ or brackets in double quotes: "Doe, Jane" <jane@b.com>. The domain must be verified on your account, or be your sandbox domain.
tostring or string[]Required. Up to 50 recipients across to, cc and bcc.
cc, bccstring or string[]Optional.
reply_tostringOptional.
subjectstringRequired unless you name a template. Up to 998 characters. Line breaks are rejected.
html, textstringAt least one is required, unless you name a template. Up to 5 MB each.
template_id, template_slugstringOptional. Send a stored template instead of a body. Name it one way or the other, not both. The template supplies the subject and body, so subject, html and text are rejected alongside it.
variablesobjectOptional. Values for the template's {{variables}}. Strings, numbers, booleans or null. Names must match ^[A-Za-z_][A-Za-z0-9_]*$; string values are capped at 10,000 characters. Rejected without a template.
headersobjectOptional. Up to 20 extra headers as name: value. You can set X- headers and In-Reply-To, References, Keywords, Comments, Auto-Submitted, Precedence, Importance, Feedback-ID, List-Id, List-Unsubscribe and List-Unsubscribe-Post. Anything else is rejected: the addresses, subject, date and MIME structure have their own fields or are set by Avelto, and headers such as Sender, Resent-*, Disposition-Notification-To, Errors-To or Received make claims about the message that are not the sender's to make. A few X- headers are rejected too: X-Report-Abuse, anything starting X-SES or X-Avelto-, and those that claim where a message came from or how it was filtered (X-Originating-IP, X-Original-*, X-Forwarded-*, X-Spam-* and similar). Names up to 78 characters; values up to 900 printable ASCII characters (encode anything else as an RFC 2047 encoded-word). Use reply_to rather than a Reply-To header.
tagsstring[]Optional. Up to 10. Letters, numbers, _ - : ., up to 64 characters each. Filter lists by tag, and see this month's sends per tag on the dashboard's Usage page.
attachmentsobject[]Optional. Up to 10 files, 7 MB in total. Each has a filename, an optional content_type, and either base64 content or a url we fetch.
scheduled_atstringOptional. ISO 8601 timestamp in the future, at most 30 days ahead.
unsubscribe_urlstringOptional. An https URL that unsubscribes the recipient, up to 900 printable ASCII characters. Setting it marks the message as bulk and adds the List-Unsubscribe and List-Unsubscribe-Post (one-click) headers. The URL must accept an unauthenticated POST. Do not also set a List-Unsubscribe header, and leave it off transactional mail.

Each address is exactly one mailbox. A display name with an unquoted , ; : @ or angle bracket is rejected rather than guessed at, and so are control characters and bidirectional formatting characters anywhere in an address. The local part (before the @) must be ASCII; a domain written in Unicode is sent in its punycode form. Avelto stores and sends each address in one canonical form: the name quoted where it needs quotes and the domain lower-cased, so Jane Doe <Jane@Example.COM> comes back as Jane Doe <Jane@example.com>.

Line breaks and control characters in the subject or a header value, and filenames containing quotes, slashes, backslashes, control or bidirectional formatting characters, are all rejected with validation_error.

Every email you send through Avelto also carries two headers that we add and you cannot change: X-Avelto-Email-ID, the email's id (the same one the API returned), and X-Report-Abuse, a link to our report page with that id filled in. They identify the message and nothing else: no account id, key or other detail of yours. A recipient who reports a message that way tells us exactly which one, and you can match the id against your own records or GET /v1/emails/:id.

Attachments

Give each attachment the bytes, or somewhere to get them:

JSON
{  "attachments": [    { "filename": "invoice.pdf", "content": "JVBERi0xLjQK..." },    { "filename": "logo.png", "url": "https://cdn.acme.com/logo.png" }  ]}

A url is fetched when the send is accepted, not later, so a broken link is an error on the call that made it rather than a surprise two seconds afterwards. It must be reachable from the public internet: private and loopback addresses are refused, and redirects are not followed, so give us the final URL. It must be https, answer within ten seconds, and return a non-empty body. URLs are fetched only once the rest of the send has passed (your account, the recipients and the sender), so a send that is refused for another reason fetches nothing. When a URL cannot be fetched, the error says timeout or could not connect rather than the network detail.

Ten files, 7 MB in total after decoding. The limit is counted while URLs download, across all of them together, so a send that goes over it is refused as soon as it does. Filenames may not contain quotes, slashes or backslashes.

Attachment names, types and sizes appear on the email in your dashboard. The bytes themselves are not available for download.

Sending a batch

Up to 100 messages in one call:

curl -X POST https://api.avelto.dev/v1/emails/batch \  -H "Authorization: Bearer av_live_..." \  -H "Content-Type: application/json" \  -d '{    "messages": [      {        "from": "Acme <billing@mail.acme.com>",        "to": "jane@example.com",        "subject": "Receipt #1042",        "text": "Thanks for your order."      },      {        "from": "Acme <billing@mail.acme.com>",        "to": "sam@example.com",        "subject": "Receipt #1043",        "text": "Thanks for your order."      }    ]  }'

Each message is validated, limited and queued exactly as a single send is. The batch is a convenience for the round trip, not a transaction. One suppressed address in a run of a hundred costs you that message and nothing else, so the response reports per message:

JSON
{  "results": [    { "ok": true, "index": 0, "id": "5f1c..." },    { "ok": false, "index": 1, "error": { "code": "recipient_suppressed", "message": "..." } }  ],  "accepted": 1,  "failed": 1}

results[i] lines up with messages[i]. The status is 200 when everything was accepted, 422 when nothing was, and 207 when it was mixed, so an all-or-nothing outcome looks like the single-send equivalent and your existing error handling still works.

Send an Idempotency-Key and the whole batch becomes replayable: a retry after a timeout returns the same ids instead of sending again. Each message is stored with the key batch:<your key>:<index>, which is why a single send may not use a key starting with batch:. Each message counts separately against your plan; the batch is a single request against the per-second rate limit.

Idempotency

Retries are safe when you send an Idempotency-Key header. The first request creates the email; a replay with the same key on the same account returns the same id with status 200 and does not send again. Keys are kept per account and per mode, so a test key reusing a live send's key creates a test email rather than replaying the live one; the first 255 characters are used (200 for a batch). Keys starting with batch: are reserved for batches and refused with 400. A replay is matched on the key alone, not the body. Send one with every request you might repeat, and retry on 429, 502, 503, 504 and network errors with a short backoff. The Node SDK does both automatically: a random key per call unless you pass one, and up to three attempts.

TypeScript
const { id } = await avelto.emails.send(  {    from: "billing@mail.acme.com",    to: "jane@example.com",    subject: "Receipt #1042",    text: "...",  },  { idempotencyKey: "receipt-1042" },);

Schedule and cancel

Set scheduled_at to hold the email until then. It sits in status scheduled and can be cancelled until it is sent.

curl -X POST https://api.avelto.dev/v1/emails \  -H "Authorization: Bearer av_live_..." \  -H "Content-Type: application/json" \  -d '{    "from": "Acme <hello@mail.acme.com>",    "to": "jane@example.com",    "subject": "Your trial ends tomorrow",    "text": "Reply to this email if you have questions.",    "scheduled_at": "2026-10-01T09:00:00Z"  }'

To call it off, send POST /v1/emails/:id/cancel with the id the send returned. Keep that id alongside whatever the email is about (the trial, the order) so you can find it again.

curl -X POST https://api.avelto.dev/v1/emails/9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10/cancel \  -H "Authorization: Bearer av_live_..."

The response is the email's id and its new status:

JSON
{ "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10", "status": "cancelled" }

Cancelling an email that is not scheduled returns 409 not_scheduled.

Validate an address

POST /v1/emails/validate says whether an address is worth a send, from everything that can be known without sending: syntax, whether the domain has a mail server, throwaway-mail domains, role addresses such as info@, domains within a typo of a well-known one, and your own suppression list. Nothing is sent or stored, so call it from a signup form on every submit. It works with a test key and needs the emails:send scope. Only a live key is checked against the suppression list; for a test key checks.suppressed is always false.

curl -X POST https://api.avelto.dev/v1/emails/validate \  -H "Authorization: Bearer av_live_..." \  -H "Content-Type: application/json" \  -d '{    "email": "jane@gmial.com"  }'
JSON
{  "email": "jane@gmial.com",  "normalized": "jane@gmial.com",  "result": "risky",  "reason": "possible_typo",  "checks": {    "syntax": true,    "mail_server": true,    "disposable": false,    "role": false,    "free_provider": false,    "suppressed": false  },  "suggestion": "jane@gmail.com"}
resultMeaningreason
deliverableNothing known counts against it.null
riskyIt may well deliver, but look twice.disposable, possible_typo (then suggestion is the corrected address to offer), role_address
undeliverableA send would fail or be refused.invalid_syntax, no_mail_server, suppressed

One reason is given, the most serious. checks carries every individual answer so you can apply your own policy, for example accepting role addresses but not throwaway domains. checks.mail_server is null when the DNS lookup did not complete in time, and that is never counted against the address. free_provider is informational and never affects the result.

What it cannot tell you is whether the mailbox exists. The large providers accept every address at delivery time and bounce later, so nobody can know that without sending, and an answer that claimed to would be a guess. Store normalized, the lower-cased form, as the address to send to and compare against.

List emails

GET /v1/emails returns the newest first, 20 per page by default and up to 100. Filter with status, tag and mode; mode defaults to the mode of the key you call with. q (up to 200 characters) searches for a substring of a recipient, the subject or the message id, or an exact email id; each row then carries match: { field, value } saying what it was found in.

curl "https://api.avelto.dev/v1/emails?status=bounced&tag=receipt&limit=50" \  -H "Authorization: Bearer av_live_..."
JSON
{  "data": [    {      "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",      "mode": "live",      "from": "Acme <billing@mail.acme.com>",      "to": ["jane@example.com"],      "cc": [],      "bcc": [],      "reply_to": null,      "subject": "Receipt #1042",      "tags": ["receipt", "order:1042"],      "status": "bounced",      "scheduled_at": null,      "created_at": "2026-09-17T10:12:04.000Z",      "updated_at": "2026-09-17T10:12:09.000Z"    }  ],  "next_cursor": null}

Pass next_cursor back as cursor to get the next page. It is null on the last page.

Statuses

StatusMeaning
queuedAccepted and waiting to be handed to the mail provider.
scheduledHeld until scheduled_at.
sentHanded off. Waiting for a delivery result.
deliveredAccepted by the recipient's mail server.
bouncedRejected by the recipient's mail server. Hard bounces add the address to your suppressions.
complainedThe recipient marked it as spam. The address is suppressed.
failedCould not be sent after retries. error says why.
cancelledA scheduled email you cancelled.

Errors

Every error is a JSON envelope with a stable code and a human-readable message. Validation errors add details.

HTTP
HTTP/1.1 403 ForbiddenContent-Type: application/json{  "error": {    "code": "domain_not_verified",    "message": "Domain mail.acme.com is pending. Add the DNS records and wait for verification before sending."  }}
JSON
{  "error": {    "code": "validation_error",    "message": "html: Provide html or text (or both)",    "details": [      { "path": "html", "message": "Provide html or text (or both)" }    ]  }}

Codes you will meet when sending:

CodeStatusWhen
validation_error400 (422 when an attachment url could not be fetched, or the fetched files exceed the size limit)The body failed validation. details lists the failing fields.
unauthorized401Missing or invalid API key.
forbidden403The key does not have the scope for this call. The message names it.
live_key_required403A test key was used for a call that needs a live one, such as changing a template or a webhook endpoint.
domain_not_verified403from uses a domain that is not added and verified on your account.
sandbox_recipient_not_allowed403Sending from the sandbox domain to anyone other than your account email.
account_paused403Sending is paused, usually after a high bounce or complaint rate.
account_suspended403The account has been suspended by us; the message says why and the dashboard has a button to write to us.
recipient_suppressed422A recipient of a live send is on your suppression list. details.suppressed lists them.
plan_limit429Monthly limit reached on Free, or the first-week daily cap of 100 emails.
rate_limited429More requests a second than your plan allows on one key (10 on Free and Pro, 25 on Growth, 50 on Enterprise). Honour Retry-After.

The full list is in the API reference.