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.
| Field | Type | Notes | Also accepted as |
|---|---|---|---|
| from | string | Required. Bare address or Name <address>. Must be on a verified domain. | |
| to | string · array | Required. Up to 50. One address, an array, or a comma-separated list. | |
| cc | string · array | Up to 50. Named in the message the recipient reads. | |
| bcc | string · array | Up to 50. Delivered to, never named in any header. | |
| subject | string | Required unless a template supplies one. | |
| html | string | At least one of html, text or template_id. | |
| text | string | Send both when you can — some clients prefer the plain part. | |
| reply_to | string · array | Where replies go, when that is not the From address. | replyTo |
| headers | object | Extra headers, e.g. your own order id for later correlation. | |
| attachments | array | Up to 20. See Attachments. | |
| tags | array · object | [{ name, value }] or a plain { name: value } object. | |
| template_id | string | A published template. Merge values go in variables. | templateId |
| variables | object | Values for a template's {{placeholders}}. | template_variables |
| send_at | string · number | ISO-8601 or a Unix timestamp, up to 30 days ahead. | sendAt, scheduled_at, scheduledAt |
| idempotency_key | string | Also accepted as the Idempotency-Key header. | idempotencyKey |
| open_tracking | boolean | Overrides the domain's default for this message. | openTracking |
| click_tracking | boolean | Rewrites 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, adata: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, otherwiseapplication/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.
| Status | error | What to do |
|---|---|---|
| 401 | missing_api_key · invalid_api_key | Check the Authorization header and that the key was not revoked. |
| 403 | domain_not_verified | The From domain is not verified. detail names it. |
| 403 | missing_scope | The key exists but lacks sending access. |
| 400 | invalid_from_address · invalid_to_address | An address could not be parsed; detail shows it. |
| 400 | subject_required · body_required | A required part of the message is missing. |
| 422 | attachment_url_not_https · attachment_url_not_public | A URL attachment is not a public https address. |
| 413 | attachment_too_large · attachments_too_large | Over 10 MB for one file, or 25 MB in total. |
| 429 | daily_quota_exceeded · monthly_quota_exceeded | Your plan's allowance is spent; the response says when it resets. |
| 409 | not_cancelable · not_reschedulable | The 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.
| Host | the host shown under Settings → SMTP in your dashboard |
| Port | 587 |
| Encryption | STARTTLS |
| Username | apikey — the value is ignored |
| Password | an 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.