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-staging.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"
      }
    ]
  }'

Fields

FieldTypeNotes
fromstringRequired. a@b.com or Name <a@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. Extra headers as name: value. Names up to 78 characters, values up to 1000, no control characters. Names that Avelto sets itself (From, To, Subject, Reply-To, Sender, Return-Path, Date, Message-ID, Received, DKIM-Signature, ARC and Authentication-Results headers, MIME headers and anything starting X-SES) are rejected. 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.
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. 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.

Addresses with line breaks, control characters in any header field, and filenames containing quotes, slashes or backslashes are all rejected with validation_error.

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.

Ten files, 7 MB in total after decoding. The limit is checked again after any URLs are fetched, because their size is not knowable before that. 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-staging.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 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; the first 255 characters are used (200 for a batch). 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-staging.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"
  }'
curl -X POST https://api-staging.avelto.dev/v1/emails/9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10/cancel \
  -H "Authorization: Bearer av_live_..."
JSON
{ "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10", "status": "cancelled" }

Cancelling an email that is not scheduled returns 409 not_scheduled.

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-staging.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 Forbidden
Content-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), or a test key was used where a live one is needed.
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 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.