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.

NameWhat it does
apiKeyRequired. A key from API keys, or a dashboard session token.
baseUrlYour API origin. Defaults to http://localhost:3000, which is only right in development.
orgIdOnly with a session token — an API key already names its organisation.
maxRetriesAttempts after the first, for 429, 5xx and network failures. Default 2; 0 turns retrying off.
timeoutMsPer attempt, not per call. Default 30000.
fetchYour 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;
}
NameWhat it does
401The key is wrong, revoked, or missing.
403 missing_scopeValid key, not this permission. The body names the scope it wanted.
403 domain_not_verifiedThe from-address is on a domain this account has not verified.
400Rejected before sending — a missing subject or body, an unreadable date, an unknown field.
413An attachment is over the size limit.
422The shape was fine, the content was not — an unpublished template, an attachment we could not fetch.
429Quota. limit, used and resets_at are in the body.
status 0Never 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

NameWhat it does
mail.health()Liveness probe.
mail.emailssend, batch, get, list, cancel, reschedule
mail.domainscreate, list, get, verify, update, delete
mail.apiKeyscreate, list, revoke
mail.templatescreate, list, get, update, delete
mail.audiencescreate, list, get, delete, importCsv
mail.contactscreate, list, get, update, delete, setTopic, exportCsv
mail.segmentscreate, list, addContacts, removeContacts, delete
mail.topicscreate, list, delete
mail.broadcastscreate, list, get, update, send, cancel, metrics, recipients, delete
mail.automationscreate, list, get, update, activate, pause, enroll, runs, events, delete
mail.eventssend, list
mail.webhookscreate, list, update, deliveries, delete
mail.suppressionslist, add, addMany, remove, removeMany
mail.inboundlist, get
mail.metricsget
mail.usageget, requestLogs

Prefer the shell? The same surface is a command away — mailctl.