API reference

One endpoint sends mail. You need an API key and a domain you have verified — nothing else, and no SDK unless you want one.

1. Create an API key

In the dashboard, open API keys and create one with sending access. The key starts with ep_ and is shown once, so store it in your application's environment now.

2. Verify the domain you will send from

Add the domain under Domains and publish the records it shows you. Until it reads Verified, every send from that domain is refused with domain_not_verified — deliberately, because unsigned mail from an unverified domain is what gets a sender blocked.

3. Send

curl -X POST https://api.esendblue.com/v1/emails \
  -H "Authorization: Bearer $ESENDBLUE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Your Company <receipts@yourdomain.com>",
    "to": "customer@example.com",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>"
  }'
const res = await fetch("https://api.esendblue.com/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ESENDBLUE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Your Company <receipts@yourdomain.com>",
    to: "customer@example.com",
    subject: "Your receipt",
    html: "<p>Thanks for your order.</p>",
  }),
});
const { id } = await res.json();
import os, requests

requests.post(
    "https://api.esendblue.com/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['ESENDBLUE_API_KEY']}"},
    json={
        "from": "Your Company <receipts@yourdomain.com>",
        "to": "customer@example.com",
        "subject": "Your receipt",
        "html": "<p>Thanks for your order.</p>",
    },
).raise_for_status()
$ch = curl_init("https://api.esendblue.com/v1/emails");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("ESENDBLUE_API_KEY"),
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "from" => "Your Company <receipts@yourdomain.com>",
    "to" => "customer@example.com",
    "subject" => "Your receipt",
    "html" => "<p>Thanks for your order.</p>",
  ]),
]);
$body = curl_exec($ch);

A successful send returns 201 and the email's id. Keep it: it is how you look the message up later, and how webhook events refer to it.

{ "object": "email", "id": "e0c18ae9-87cf-4597-8777-9c1ee92bf28f" }

Authentication

Every request carries the key as a bearer token: Authorization: Bearer ep_…. The key identifies the organization, so there is no account id to send alongside it.

Keys are scoped. A key that can send cannot manage domains or read billing, so the key in your web server's environment should be a sending key and nothing more. Treat it like a database password: server side only, never in a browser bundle or a mobile app.

Base URL. https://api.esendblue.com. Every endpoint below also answers without the /v1 segment, so a client written for another provider usually needs nothing but a new base URL.

Sending domains

The address in from must be on a verified domain. That is the one rule the API will not bend, and it is what makes the mail deliverable: the platform signs every message with a DKIM key held for that domain, and bounces return to a path on the domain itself rather than to a shared address.

A subdomain is the usual choice — mail.yourdomain.com or email.yourdomain.com. Sending from a subdomain keeps the reputation of your transactional mail separate from the mail your staff sends by hand, so a bad week for one does not become a bad week for both.

Send an email

POST /v1/emails

from and to are always required, along with at least one of html, text or template_id. Everything else is optional.

{
  "from": "Your Company <receipts@yourdomain.com>",
  "to": ["Asha Devi <asha@example.com>"],
  "cc": "accounts@example.com",
  "bcc": ["archive@example.com"],
  "reply_to": "support@yourdomain.com",
  "subject": "Your receipt",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "headers": { "X-Order-Id": "1042" },
  "tags": { "kind": "receipt" },
  "click_tracking": false,
  "idempotency_key": "receipt-1042"
}

Field reference

The right-hand column lists names that mean the same thing. They exist so that a payload written for another provider, or by a client library that camel-cases its keys, is understood rather than rejected.

FieldTypeNotesAlso accepted as
fromstringRequired. Bare address or Name <address>. Must be on a verified domain.
tostring · arrayRequired. Up to 50. One address, an array, or a comma-separated list.
ccstring · arrayUp to 50. Named in the message the recipient reads.
bccstring · arrayUp to 50. Delivered to, never named in any header.
subjectstringRequired unless a template supplies one.
htmlstringAt least one of html, text or template_id.
textstringSend both when you can — some clients prefer the plain part.
reply_tostring · arrayWhere replies go, when that is not the From address.replyTo
headersobjectExtra headers, e.g. your own order id for later correlation.
attachmentsarrayUp to 20. See Attachments.
tagsarray · object[{ name, value }] or a plain { name: value } object.
template_idstringA published template. Merge values go in variables.templateId
variablesobjectValues for a template's {{placeholders}}.template_variables
send_atstring · numberISO-8601 or a Unix timestamp, up to 30 days ahead.sendAt, scheduled_at, scheduledAt
idempotency_keystringAlso accepted as the Idempotency-Key header.idempotencyKey
open_trackingbooleanOverrides the domain's default for this message.openTracking
click_trackingbooleanRewrites links to measure clicks. Turn it off for one-time codes and receipts.clickTracking

Cc, Bcc and display names

A display name may be written into any address — "Asha Devi <asha@example.com>" — and it is carried through to the header the recipient sees. An array of { "email": "…", "name": "…" } objects works too, which is usually the shape a contact record is already in.

Bcc behaves the way Bcc is supposed to: those addresses are delivered to, but no header in the delivered message names them, so nobody on the To or Cc line learns who else received a copy.

{
  "to": [{ "email": "asha@example.com", "name": "Asha Devi" }],
  "cc": "accounts@example.com, audit@example.com",
  "bcc": ["archive@example.com"]
}

A repeated address is collapsed to one recipient, so a contact that appears twice in your list does not receive two copies.

Attachments

Up to 20 files, 10 MB each and 25 MB in total. Give the bytes directly, or a URL for the platform to fetch.

{
  "attachments": [
    { "filename": "invoice.pdf", "content": "JVBERi0xLjQg…" },
    { "path": "https://yourdomain.com/invoices/1042.pdf" },
    {
      "filename": "logo.png",
      "content": "iVBORw0KGgo…",
      "content_id": "logo"
    }
  ]
}
  • content — base64, a data: URI, or the bytes themselves if your language serialises a buffer into JSON.
  • path — a public https URL. The filename is taken from the URL unless you give one.
  • content_id — makes the file inline, referenced from the HTML as <img src="cid:logo">.
  • content_type — optional; guessed from the response when fetched by URL, otherwise application/octet-stream.

A URL attachment must be reachable from the public internet over https. Addresses on private networks are refused, and so is a redirect that leads to one. If the file lives behind your own login, read it in your application and send the bytes.

For a receipt or an invoice, a link in the body is often better than an attachment: it survives mailbox size limits, it can be revoked, and you can see when it was opened. Attach the PDF when the recipient needs to keep it without visiting anything.

Scheduling

Set send_at to a time up to 30 days ahead. A time in the past sends immediately rather than failing.

curl -X POST https://api.esendblue.com/v1/emails/$ID/cancel \
  -H "Authorization: Bearer $ESENDBLUE_API_KEY"

curl -X PATCH https://api.esendblue.com/v1/emails/$ID \
  -H "Authorization: Bearer $ESENDBLUE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"send_at": "2026-12-01T09:00:00Z"}'

Both work only while the message is still queued. Once it has left, they answer 409 with the status it actually reached — mail cannot be recalled.

Idempotency

Give a send an idempotency_key and a repeat of the same key returns the original email's id instead of sending again. The key is scoped to your organization and never expires.

This matters most where you do not control how often your code runs: a payment gateway that retries its webhook, a queue that redelivers a job, a user who submits a form twice. Key it on the thing the mail is about — receipt-1042, not a random value — and the retry is free.

Batch

POST /v1/emails/batch takes up to 100 messages in one request and reports each result in order. One bad message does not fail the others.

{
  "data": [
    { "object": "email", "id": "c2b6b741-…" },
    { "error": "domain_not_verified", "detail": "other.org" }
  ]
}

Batch is for unrelated transactional messages. To send one message to a list of people, use a broadcast — it handles unsubscribes, suppression and pacing, which a loop over this endpoint does not.

Retrieve and list

GET /v1/emails/:id returns the message with its full delivery timeline: when it was queued, each attempt, the SMTP code the receiving server gave, and whether it was opened or clicked.

GET /v1/emails lists them, newest first, with status, q (subject, from or recipient), from and to (as YYYY-MM-DD day bounds), limit and offset.

For anything time-sensitive, prefer a webhook over polling this: the platform will tell you about a delivery, bounce, open or click as it happens.

Errors

Failures come back as { "error": "…", "detail": "…" } with a conventional status code. detail names the specific value that was wrong.

StatuserrorWhat to do
401missing_api_key · invalid_api_keyCheck the Authorization header and that the key was not revoked.
403domain_not_verifiedThe From domain is not verified. detail names it.
403missing_scopeThe key exists but lacks sending access.
400invalid_from_address · invalid_to_addressAn address could not be parsed; detail shows it.
400subject_required · body_requiredA required part of the message is missing.
422attachment_url_not_https · attachment_url_not_publicA URL attachment is not a public https address.
413attachment_too_large · attachments_too_largeOver 10 MB for one file, or 25 MB in total.
429daily_quota_exceeded · monthly_quota_exceededYour plan's allowance is spent; the response says when it resets.
409not_cancelable · not_reschedulableThe message already left the queue.

A 5xx is safe to retry. Retry with an idempotency_key and a duplicate cannot happen even if the first attempt did in fact go through.

SMTP

If your application already speaks SMTP — a mail library, a CMS plugin, a framework's mailer — point it here instead of rewriting it. Messages arriving this way go through exactly the same pipeline: verified domains, quotas, suppression, DKIM signing and tracking.

Hostthe host shown under Settings → SMTP in your dashboard
Port587
EncryptionSTARTTLS
Usernameapikey — the value is ignored
Passwordan API key with sending access

Attachments, Cc, Bcc, Reply-To, display names and your own custom headers all survive submission. Bcc is reconstructed from the envelope, so it stays blind.

If the server answers 4xx, the message was not accepted but the condition is temporary — your quota for the day, most often — and retrying later will work. A 5xx means the message will never be accepted as written.

Coming from Resend

The request and response shapes match, including the endpoint paths without a version segment. In most integrations the change is the base URL and the key:

- https://api.resend.com/emails
+ https://api.esendblue.com/emails

Payloads written in camelCase (replyTo, scheduledAt) are understood, a Buffer serialised into JSON is read as attachment content, and batch responses carry data as well as emails. What does not carry over is anything rendered in the client — React Email components, for instance — because this API takes HTML. Render to HTML first and send that.

The one thing you must redo is the domain: DKIM keys are not portable, so the domain has to be verified here before it can send.