Skip to main content
Tutorial

Send your first email from Node.js

Send email from Node.js with the mailkube SDK: install, key, first message, then scheduling, templates, tags, topics and webhook verification.

mailkube 5 min read Updated August 18, 2026
sdkwebhooks

To send email from Node.js you need three things: the SDK, a verified domain and an API key. By the end of this you will have sent a real transactional message from a Node script and seen what the platform did with it. It takes about four minutes.

Prerequisites

  • Node.js 20.3 or newer. That is the floor in the package’s engines field. Below it the client refuses to start rather than failing later, with the message “This SDK requires Node 20.3 or later”.
  • A mailkube account with one verified domain. Verification means the SPF and DKIM records are published and checked, and the dashboard shows a green state when it is done. If you have not got there yet, what each authentication record proves covers the four records and where they go.
  • An API key. Copy it when it is created, because it is shown once.

1. Install the SDK

npm install @mailkube/mailkube-node

It has no runtime dependencies, so that command pulls in one package and nothing else. Both import and require resolve, which means it drops into an ESM project and a CommonJS one without a bundler step in between.

2. Set your key as an environment variable

Never put the key in the source file. It ends up in git.

export MAILKUBE_API_KEY="mk_yourkeyid_yoursecret"

The client reads that variable on its own. When the key arrives some other way, pass it explicitly with new Mailkube({ apiKey }).

3. Send the message

Create send.js:

import { Mailkube } from '@mailkube/mailkube-node';

const client = new Mailkube();

const email = await client.emails.send({
  from: 'billing@example.com',      // must be on your verified domain
  to: 'you@example.com',
  subject: 'Your receipt',
  html: '<p>Thanks for your order.</p>',
  tags: [{ name: 'kind', value: 'receipt' }],
});

console.log(email.id, email.messageId);

Run it:

node send.js

Two identifiers come back:

4a7b2c1e-9f30-4d88-b1a2-6c5e8f0d3a41 <20260805090000.7f3a@send.example.com>

The first is the mailkube id, and it is what you use against the API afterwards. The second is the Message-ID that ends up in the recipient’s headers and in their provider’s logs. Keep it if you ever have to trace one message through somebody else’s mail server.

4. Verify it worked

Two checks, and do both. The inbox tells you it arrived. The logs tell you what the platform actually did with it.

Open your sending logs and filter by the tag kind: receipt. You should see one message with an accepted event, followed by delivered once the receiving server confirms. A message stuck on accepted is still in flight or was deferred, which is normal for the first few seconds.

What else the SDK does

Most of the surface is the same send call with more fields on it.

Schedule it. Pass scheduledAt and the message is held instead of sent. batchId groups a run so you can move or cancel the whole thing later.

const email = await client.emails.send({
  ...params,
  scheduledAt: '2026-08-20T07:00:00Z',   // ISO 8601 with an offset, or a Date
  batchId: 'welcome-wave-3',
});

console.log(email.isScheduled, email.status);   // true scheduled

A scheduled message stays editable until it goes out. client.scheduledEmails carries get, update, cancel and list, plus iterAll for walking every page without writing the pagination loop. client.scheduledEmails.batches.cancel('welcome-wave-3') cancels the group and reports how many it caught.

Render a template. Send a templateId and the variables it expects instead of a body.

await client.emails.send({
  ...params,
  templateId: 'tpl_7f3a9c',
  templateVersion: 'latest',
  variables: { first_name: 'Sam', order_id: '1234' },
});

Label it, and attribute it. Tags are your own metadata, denormalized onto the sending log so you can filter and export by them, and they ride along on delivery webhooks. Names and values allow [A-Za-z0-9_-], a name up to 16 characters and a value up to 32, at most 20 per send. Tag values are not encrypted, so keep personal data out of them. A topic is different: it is a subscription group your recipients can leave individually, so the unsubscribe link removes them from that one list rather than from everything you send.

await client.emails.send({
  ...params,
  tags: [{ name: 'campaign', value: 'onboarding' }],
  topic: 'newsletter',
});

An unknown or disabled topic slug is rejected before the message is charged or queued, so a typo costs you nothing and never silently sends without the topic.

Attach a file, and make a retry safe. Attachment content is a base64 string or raw bytes as a Uint8Array. An idempotencyKey makes the same call twice produce one message, which matters wherever something above you retries.

const email = await client.emails.send({
  ...params,
  attachments: [{ filename: 'receipt.pdf', content: bytes, contentType: 'application/pdf' }],
  idempotencyKey: 'order-1234-receipt',
});

console.log(email.idempotentReplayed);   // true when this call replayed an earlier one

The key is remembered for 24 hours and fingerprinted against the request body, so reusing it with different content raises an error rather than quietly replaying the old message.

Verify a webhook. One verify call checks the signature and gives you back a typed event.

import { verify } from '@mailkube/mailkube-node';

const event = await verify(rawBody, headers, process.env.MAILKUBE_WEBHOOK_SECRET);

if (event.type === 'email.bounced') {
  console.log(event.data.bounce.reason, event.data.bounce.code);
}

It needs the raw body, not a parsed one, because the signature covers the exact bytes that arrived. Framework-specific wiring for that is in sending email from Express, Fastify, Next.js and NestJS, and the same helper runs unchanged on edge runtimes, covered in sending email from a serverless function.

Troubleshooting

403 invalid_api_key. One status covers a wrong key, a revoked key, an inactive account and a domain whose DNS is not published yet. That is deliberate: an unauthenticated caller learns nothing about the state of an account from the answer. Check the key first, then the domain page, and remember propagation can take up to an hour depending on your registrar’s TTL.

422 from_domain_not_allowed. The key is fine and the from address is well formed, but that domain is not the one bound to the key. This is the anti-spoofing check, and it is a different failure from the one above.

400 missing_user_agent. You are calling the REST API directly rather than through the SDK, with no User-Agent header. This is a server-to-server API and it rejects unidentified clients. The SDK sets the header for you.

Next steps

The Node SDK reference has the full parameter list, and every name and status the API can return is in the error reference . If you would rather not add a dependency at all, the same message goes over SMTP with the same tags, topics and templates.

Start sending in minutes

Create an account, verify a domain, and send your first message today.

Get started