Sending email from Node.js requires three things: the SDK, a verified sending domain and an API key. This tutorial covers all three and ends with a real transactional message sent from a Node script, in about four minutes.
Prerequisites
- Node.js 22.12 or newer. That is the floor declared in the package’s
enginesfield. The client resolves the runtime’s globalfetchwhen it is constructed and raises a named error if none is available, rather than surfacing the problem later as a network failure. - A mailkube account with an active sending domain. A domain becomes active once its records are published and checked. What each authentication record proves covers the four records and where they belong.
- An API key. In the dashboard, open Credentials, select the API Keys tab and choose Create. The button stays disabled until the domain is active, which is why domain verification comes first. The secret is displayed once, at creation, so copy it before closing the dialog.
1. Install the SDK
npm install @mailkube/mailkube-node
The package has no runtime dependencies, so the command installs one package and nothing else. Both import and require resolve, so it fits an ESM project and a CommonJS one without an intermediate bundler step.
2. Set your key as an environment variable
Keep the key out of your source files, which are committed to version control.
export MAILKUBE_API_KEY="mk_yourkeyid_yoursecret"
The client reads that variable automatically. Where the key arrives by another route, 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, used for every subsequent API call about that message. The second is the Message-ID carried in the recipient’s headers and in their provider’s logs. Retain it for any case where a message has to be traced through a recipient’s mail server.
4. Verify it worked
Run two checks. The inbox confirms the message arrived. The sending logs record what the platform did with it.
Open your sending logs and filter by the tag kind: receipt. One message should appear with an accepted event, followed by delivered once the receiving server confirms. A message still showing accepted is in flight or has been deferred, which is expected for the first few seconds.
What else the SDK does
Most of the SDK’s surface is the same send call with additional fields.
Scheduling. Pass scheduledAt and the message is held until that time. batchId groups a run so it can be rescheduled or cancelled as a unit.
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 remains editable until it is sent. client.scheduledEmails provides 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 messages it covered.
Templates. Send a templateId and the variables it expects in place of a body.
await client.emails.send({
...params,
templateId: 'tpl_7f3a9c',
templateVersion: 'latest',
variables: { first_name: 'Sam', order_id: '1234' },
});
Tags and topics. Tags are your own metadata. They are denormalized onto the sending log, so you can filter and export by them, and they are carried on delivery webhooks. Names and values accept [A-Za-z0-9_-], with a name of up to 16 characters, a value of up to 32, and at most 20 tags per send. Tag values are not encrypted, so they must not carry personal data. A topic serves a different purpose: it is a subscription group recipients can leave individually, so the unsubscribe link removes them from that 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 is refused and nothing goes out stripped of its topic.
Attachments and idempotency. Attachment content is a base64 string or raw bytes in a Uint8Array. An idempotencyKey makes the same call twice produce a single message, which matters wherever an upstream component 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 retained for 24 hours and fingerprinted against the request body. Reuse it with different content and the call raises an error.
Webhook verification. A single verify call checks the signature and returns 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 requires the raw body, 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. A single status covers an incorrect key, a revoked key, an inactive account and a domain whose DNS is not yet published. This is deliberate. An unauthenticated caller learns nothing about an account’s state from the response. Check the key first, then the domain page, allowing for propagation of up to an hour depending on your registrar’s TTL.
422 from_domain_not_allowed. The key is valid and the from address is well formed, but the domain is not the one bound to the key. This is the anti-spoofing check, and a distinct failure from the one above.
400 missing_user_agent. The REST API is being called directly, with no User-Agent header. This is a server-to-server API and it rejects unidentified clients. The SDK sets the header on every request.
Next steps
The Node SDK reference(opens in a new tab) has the full parameter list, and every name and status the API can return is in the error reference(opens in a new tab) . The same message can also be sent over SMTP, with the same tags, topics and templates.