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.
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}` },);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.
// 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
| Field | Type | Notes |
|---|---|---|
from | string | Required. 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. |
to | string or string[] | Required. Up to 50 recipients across to, cc and bcc. |
cc, bcc | string or string[] | Optional. |
reply_to | string | Optional. |
subject | string | Required unless you name a template. Up to 998 characters. Line breaks are rejected. |
html, text | string | At least one is required, unless you name a template. Up to 5 MB each. |
template_id, template_slug | string | Optional. 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. |
variables | object | Optional. 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. |
headers | object | Optional. 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. |
tags | string[] | 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. |
attachments | object[] | 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_at | string | Optional. ISO 8601 timestamp in the future, at most 30 days ahead. |
unsubscribe_url | string | Optional. 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:
{ "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:
{ "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.
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:
{ "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" }'{ "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"}result | Meaning | reason |
|---|---|---|
deliverable | Nothing known counts against it. | null |
risky | It may well deliver, but look twice. | disposable, possible_typo (then suggestion is the corrected address to offer), role_address |
undeliverable | A 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_..."{ "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
| Status | Meaning |
|---|---|
queued | Accepted and waiting to be handed to the mail provider. |
scheduled | Held until scheduled_at. |
sent | Handed off. Waiting for a delivery result. |
delivered | Accepted by the recipient's mail server. |
bounced | Rejected by the recipient's mail server. Hard bounces add the address to your suppressions. |
complained | The recipient marked it as spam. The address is suppressed. |
failed | Could not be sent after retries. error says why. |
cancelled | A 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/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." }}{ "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:
| Code | Status | When |
|---|---|---|
validation_error | 400 (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. |
unauthorized | 401 | Missing or invalid API key. |
forbidden | 403 | The key does not have the scope for this call. The message names it. |
live_key_required | 403 | A test key was used for a call that needs a live one, such as changing a template or a webhook endpoint. |
domain_not_verified | 403 | from uses a domain that is not added and verified on your account. |
sandbox_recipient_not_allowed | 403 | Sending from the sandbox domain to anyone other than your account email. |
account_paused | 403 | Sending is paused, usually after a high bounce or complaint rate. |
account_suspended | 403 | The account has been suspended by us; the message says why and the dashboard has a button to write to us. |
recipient_suppressed | 422 | A recipient of a live send is on your suppression list. details.suppressed lists them. |
plan_limit | 429 | Monthly limit reached on Free, or the first-week daily cap of 100 emails. |
rate_limited | 429 | More 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.