Node SDK
The same HTTP API with the tedious parts already written: typed responses, retries that cannot duplicate a send, and one error class with the API's own code on it.
npm i ./esendblue-sdk.tgz
Node 18 or newer. Nothing is bundled — it uses the runtime's own
fetch.
Not on the public npm registry yet. Until it is, the
package arrives as a tarball: run npm pack in
packages/sdk and install the file it writes, or commit it
to your own private registry. Every example below is unchanged either
way — only the install line moves.
Configure
import { Esendblue } from '@email-platform/sdk';
const mail = new Esendblue({
apiKey: process.env.ESENDBLUE_KEY,
baseUrl: 'https://api.esendblue.com',
});
Create it once and keep it. It holds no connection and no state beyond these options.
| Name | What it does |
|---|---|
apiKey | Required. A key from API keys, or a dashboard session token. |
baseUrl | Your API origin. Defaults to http://localhost:3000, which is only right in development. |
orgId | Only with a session token — an API key already names its organisation. |
maxRetries | Attempts after the first, for 429, 5xx and network failures. Default 2; 0 turns retrying off. |
timeoutMs | Per attempt, not per call. Default 30000. |
fetch | Your own implementation, for a proxy agent or a test double. |
Send an email
const { id } = await mail.emails.send({
from: 'Your Company <receipts@yourdomain.com>',
to: ['customer@example.com'],
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
text: 'Thanks for your order.',
});
to, cc, bcc and
reply_to each take a string or an array, and a display name
can travel with the address as "Name <a@b.com>". The
id that comes back is what every later call uses.
Accepted is not delivered. The call returns when the
message is ours to send. What became of it is on the email itself —
mail.emails.get(id) — or on the webhook you subscribed to.
await mail.emails.get(id); // status and the events it collected
await mail.emails.list({ status: 'bounced', limit: 50 });
await mail.emails.cancel(id); // only while it is still scheduled
Templates
Keep the wording out of your deployment. A template is a saved subject
and body with {{variables}} in it; you send the id and the
values.
await mail.emails.send({
from: 'Your Company <otp@yourdomain.com>',
to: 'customer@example.com',
template_id: 'tpl_xxx',
variables: { name: 'Ramesh', code: '481920' },
});
A template must be published before it can be sent. A
draft answers 422 template_not_published — the one
surprise that catches almost everybody once.
Attachments
await mail.emails.send({
from: 'Your Company <receipts@yourdomain.com>',
to: 'customer@example.com',
subject: 'Your invoice',
html: '<p>Attached.</p>',
attachments: [
{ filename: 'invoice.pdf', content: pdfBuffer.toString('base64') },
{ filename: 'logo.png', url: 'https://yourdomain.com/logo.png', content_id: 'logo' },
],
});
content takes base64, a data URI, or the JSON form of a
Buffer. url is fetched by us instead — https only, and
never to an address inside a private network. A
content_id makes the part inline, for
<img src="cid:logo">.
Scheduling
const { id } = await mail.emails.send({
from: 'Your Company <news@yourdomain.com>',
to: 'customer@example.com',
subject: 'Tomorrow',
html: '<p>See you then.</p>',
scheduled_at: '2026-09-06T09:00:00Z', // send_at is accepted too
});
await mail.emails.reschedule(id, '2026-09-07T09:00:00Z');
await mail.emails.cancel(id);
Batch
Up to a hundred messages in one call, each the shape of a single send. Every item reports its own result, so one bad address does not lose the other ninety-nine.
const res = await mail.emails.batch(orders.map((o) => ({
from: 'Your Company <receipts@yourdomain.com>',
to: o.email,
template_id: 'tpl_receipt',
variables: { name: o.name, amount: String(o.amount) },
})));
res.emails.forEach((r, i) => {
if ('error' in r) console.error(orders[i].email, r.error);
});
Idempotency
Every POST carries an idempotency key. If you do not supply
one the SDK generates it, so a retry after a timeout returns the
original message rather than sending a second.
await mail.emails.send(receipt, { idempotencyKey: `receipt-${invoiceId}` });
Supply your own for anything that must not happen twice. A generated key protects a retry inside one call; yours protects a retry after the process died and came back — a queue job picked up again, a webhook redelivered. Two deliberate identical sends still get two keys, and stay two sends.
Contacts and audiences
const { id: audienceId } = await mail.audiences.create('Donors');
await mail.contacts.create(audienceId, {
email: 'donor@example.com',
first_name: 'Ramesh',
properties: { city: 'Mumbai', plan: 'monthly' },
});
await mail.audiences.importCsv(audienceId, csvText); // needs an "email" column
const csv = await mail.contacts.exportCsv(audienceId);
Properties you define are available to segment on and as
{{variables}} in broadcasts and automations, alongside
{{email}}, {{first_name}} and
{{last_name}}.
Segments and topics
A segment is a saved group. A topic is something a person can leave without leaving everything — which is what keeps a list alive.
const { id: segmentId } = await mail.segments.create(audienceId, 'Monthly donors');
await mail.segments.addContacts(segmentId, [contactId]);
const { id: topicId } = await mail.topics.create('Product updates', {
default_subscribed: true,
});
await mail.contacts.setTopic(contactId, topicId, false);
Broadcasts
const { id } = await mail.broadcasts.create({
name: 'September update',
audience_id: audienceId,
segment_id: segmentId, // optional, narrows it
topic_id: topicId, // so unsubscribing mutes one thing
from: 'Your Company <news@yourdomain.com>',
subject: 'What we did in September',
html: '<p>…</p>',
preview_text: 'Three things worth two minutes.',
});
await mail.broadcasts.send(id, '2026-09-10T09:00:00Z'); // omit to send now
await mail.broadcasts.metrics(id);
Suppressed, unsubscribed and topic-opted-out contacts are skipped for you. If the plan runs out mid-send the broadcast pauses where it is and resumes when the quota resets — nobody skipped, nobody sent to twice.
Automations
A trigger and a sequence of steps, run per contact. Triggers are
contact.created, contact.added_to_segment,
event and api; steps are delay,
send_email, condition,
wait_for_event, update_contact,
add_to_segment and delete_contact.
const { id } = await mail.automations.create({
name: 'Welcome',
trigger_type: 'contact.created',
trigger_config: { audience_id: audienceId },
from: 'Your Company <hello@yourdomain.com>',
steps: [
{ type: 'send_email', config: { template_id: 'tpl_welcome' } },
{ type: 'delay', config: { minutes: '4320' } },
{ type: 'send_email', config: { template_id: 'tpl_day_three' } },
],
});
await mail.automations.activate(id);
await mail.events.send('cart.abandoned', { email: 'donor@example.com' }, { cart: 'c_12' });
An automation refuses to activate while its trigger is incomplete, which is deliberate: one live with half a trigger is worse than one that is off.
Domains and keys
const domain = await mail.domains.create('mail.yourcompany.com');
domain.records.forEach((r) => console.log(r.type, r.name, r.value));
await mail.domains.verify(domain.id);
await mail.apiKeys.create('checkout service', ['emails:send']);
Give each integration its own key with only the scopes it needs, so revoking a leaked one costs you that integration and nothing else.
Webhooks
const { id, secret } = await mail.webhooks.create('https://yourapp.com/hooks/mail', [
'email.delivered',
'email.bounced',
'email.complained',
]);
await mail.webhooks.deliveries(id); // every attempt, with the response
The secret is shown once. Verify the x-webhook-signature
header before trusting a delivery — the
API reference has the exact scheme.
Suppressions
await mail.suppressions.list({ limit: 100 });
await mail.suppressions.add('bad@example.com', 'manual');
await mail.suppressions.remove('good@example.com');
Hard bounces and complaints land here on their own and are honoured on every send. It is the thing standing between one stale import and your password resets going to spam.
Errors
Anything but a 2xx throws EsendblueError, which carries the
HTTP status, the API's own machine-readable code, and the parsed body.
import { Esendblue, EsendblueError } from '@email-platform/sdk';
try {
await mail.emails.send(message);
} catch (err) {
if (err instanceof EsendblueError) {
if (err.code === 'domain_not_verified') { /* your DNS is not done */ }
if (err.status === 429) { /* quota; err.body.resets_at says when */ }
console.error(err.code, err.status, err.body);
}
throw err;
}
| Name | What it does |
|---|---|
401 | The key is wrong, revoked, or missing. |
403 missing_scope | Valid key, not this permission. The body names the scope it wanted. |
403 domain_not_verified | The from-address is on a domain this account has not verified. |
400 | Rejected before sending — a missing subject or body, an unreadable date, an unknown field. |
413 | An attachment is over the size limit. |
422 | The shape was fine, the content was not — an unpublished template, an attachment we could not fetch. |
429 | Quota. limit, used and resets_at are in the body. |
status 0 | Never reached us: a timeout, DNS, or a refused connection. |
Retries
408, 429, 500, 502, 503, 504 and network failures are retried twice by
default, backing off exponentially and honouring Retry-After
when the server sets one. Everything else throws at once — a 422 will
not become a 200 on the second try.
The client refuses to follow a redirect and reports
unexpected_redirect instead. Following one would carry your
API key to whatever host the Location named, and the API
never redirects; if you see this, baseUrl is wrong.
Every method
| Name | What it does |
|---|---|
mail.health() | Liveness probe. |
mail.emails | send, batch, get, list, cancel, reschedule |
mail.domains | create, list, get, verify, update, delete |
mail.apiKeys | create, list, revoke |
mail.templates | create, list, get, update, delete |
mail.audiences | create, list, get, delete, importCsv |
mail.contacts | create, list, get, update, delete, setTopic, exportCsv |
mail.segments | create, list, addContacts, removeContacts, delete |
mail.topics | create, list, delete |
mail.broadcasts | create, list, get, update, send, cancel, metrics, recipients, delete |
mail.automations | create, list, get, update, activate, pause, enroll, runs, events, delete |
mail.events | send, list |
mail.webhooks | create, list, update, deliveries, delete |
mail.suppressions | list, add, addMany, remove, removeMany |
mail.inbound | list, get |
mail.metrics | get |
mail.usage | get, requestLogs |
Prefer the shell? The same surface is a command away — mailctl.