Paulo CastellanoHow to send transactional email with Node.js
Learn how to send transactional emails from your Node.js app using the Sendkit SDK, with examples for HTML, attachments, webhooks, and error handling.

Transactional emails are the messages your app sends in response to a user action: password resets, order confirmations, login codes, invoice receipts. Unlike marketing campaigns, they're triggered by your code and expected by the recipient. If they don't arrive quickly, users notice.
This guide walks through sending transactional email from a Node.js application using the Sendkit SDK. We'll cover plain text, HTML, attachments, webhook handling, and error management.
Install the SDK
Pull in the Sendkit package:
npm install @sendkitdev/sdkYou'll need an API key from your Sendkit dashboard. API keys start with sk_live_ for production and sk_test_ for development. The test key works identically but doesn't actually deliver mail — useful for integration tests.
Send a basic email
Here's the minimum viable send:
import { Sendkit } from '@sendkitdev/sdk';
const sendkit = new Sendkit('sk_live_your_api_key');
const send = async () => {
const { data, error } = await sendkit.emails.send({
from: '[email protected]',
to: '[email protected]',
subject: 'Your invoice is ready',
text: 'Invoice #1042 for $29.00 is attached to your account.',
});
if (error) {
console.error(error.name, error.message);
return;
}
console.log(data.id); // msg_7a3b...
};
send();The from address must belong to a verified domain in your Sendkit account. The to field accepts a single email string or an array of strings for multiple recipients.
The SDK uses a result pattern — every call returns { data, error } instead of throwing exceptions. Check error first, then use data.id to track delivery status later.
Send HTML email
Most transactional emails need HTML formatting. You can pass both text and html — the recipient's mail client picks whichever it supports. Always include a text fallback.
const sendInvoiceEmail = async (recipient, invoiceData) => {
const { data, error } = await sendkit.emails.send({
from: '[email protected]',
to: recipient,
subject: `Invoice #${invoiceData.number}`,
text: `Invoice #${invoiceData.number} for $${invoiceData.total}. View it at ${invoiceData.url}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Invoice #${invoiceData.number}</h2>
<p>Amount due: <strong>$${invoiceData.total}</strong></p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<thead>
<tr style="border-bottom: 1px solid #ddd;">
<th style="text-align: left; padding: 8px;">Item</th>
<th style="text-align: right; padding: 8px;">Amount</th>
</tr>
</thead>
<tbody>
${invoiceData.items.map((item) => `
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 8px;">${item.name}</td>
<td style="text-align: right; padding: 8px;">$${item.price}</td>
</tr>
`).join('')}
</tbody>
</table>
<a href="${invoiceData.url}" style="background: #0066ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
View invoice
</a>
</div>
`,
});
if (error) {
console.error(error.name, error.message);
return null;
}
return data.id;
};Inline styles are intentional. Most email clients strip <style> tags and ignore external stylesheets. Inline CSS is the only reliable approach.

Send with attachments
Pass file attachments as an array of objects. Each needs a filename, content (as a Buffer or base64 string), and contentType.
import { readFile } from 'node:fs/promises';
const sendWithAttachment = async (recipient, pdfPath) => {
const pdfBuffer = await readFile(pdfPath);
const { data, error } = await sendkit.emails.send({
from: '[email protected]',
to: recipient,
subject: 'Your receipt',
text: 'Your receipt is attached.',
html: '<p>Your receipt is attached.</p>',
attachments: [
{
filename: 'receipt.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
if (error) {
console.error(error.name, error.message);
}
};Keep total attachment size under 10MB. If you're sending larger files, include a download link instead.
You can also attach multiple files by adding more objects to the array:
const attachments = [
{
filename: 'receipt.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
},
{
filename: 'terms.pdf',
content: termsBuffer,
contentType: 'application/pdf',
},
];Handle delivery events with webhooks
Sending is half the job. You also need to know what happened after — did the email bounce? Did the user open it? Sendkit pushes these events to a webhook endpoint you configure.
Set up a webhook URL in your Sendkit dashboard under Settings > Webhooks, then handle incoming events in your app:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.SENDKIT_WEBHOOK_SECRET;
function verifySignature(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(body))
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
app.post('/webhooks/sendkit', (req, res) => {
const signature = req.headers['x-sendkit-signature'];
if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { type, data } = req.body;
switch (type) {
case 'email.delivered':
console.log(`Delivered: ${data.messageId} to ${data.to}`);
break;
case 'email.bounced':
console.log(`Bounced: ${data.messageId} — ${data.bounceType}`);
// Mark the address as undeliverable in your database
break;
case 'email.complained':
console.log(`Spam complaint: ${data.messageId}`);
// Remove this address from future sends
break;
case 'email.opened':
console.log(`Opened: ${data.messageId}`);
break;
case 'email.clicked':
console.log(`Clicked: ${data.url} in ${data.messageId}`);
break;
}
res.status(200).json({ received: true });
});
app.listen(3000);A few things to pay attention to here. Always verify the webhook signature before processing. The x-sendkit-signature header is an HMAC-SHA256 of the request body. The example above uses crypto.timingSafeEqual to prevent timing attacks during comparison.
Handle email.bounced events seriously. Continuing to send to bounced addresses damages your sender reputation and will eventually affect deliverability for all your users. Store bounce events and suppress those addresses from future sends.
Respond with a 200 status quickly. If your endpoint takes too long or returns an error, Sendkit will retry the webhook delivery with exponential backoff.

Error handling
Network requests fail. APIs return errors. Your code should handle both gracefully.
const sendEmailSafely = async (params) => {
const { data, error } = await sendkit.emails.send(params);
if (!error) {
return { success: true, messageId: data.id };
}
if (error.statusCode === 422) {
// Validation error — bad email address, missing field, etc.
console.error('Validation error:', error.message);
return { success: false, error: error.message, retryable: false };
}
if (error.statusCode === 429) {
// Rate limited — back off and retry
console.warn('Rate limited. Retrying later.');
return { success: false, error: 'rate_limited', retryable: true, retryAfter: 60 };
}
if (error.statusCode >= 500) {
// Server error — retry with backoff
console.error('Sendkit server error:', error.statusCode);
return { success: false, error: 'server_error', retryable: true };
}
// Network error or something unexpected
console.error('Unexpected error:', error.name, error.message);
return { success: false, error: 'unknown', retryable: true };
};For production applications, wrap retryable errors in a proper retry mechanism. A simple one:
const sendWithRetry = async (params, maxRetries = 3) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const result = await sendEmailSafely(params);
if (result.success || !result.retryable) {
return result;
}
const delay = result.retryAfter
? result.retryAfter * 1000
: Math.min(1000 * 2 ** attempt, 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
return { success: false, error: 'max_retries_exceeded', retryable: false };
};This uses exponential backoff and respects the Retry-After header when present. Three retries is usually enough — if the API is still down after that, queue the email for later processing rather than blocking the user's request.
SMTP as an alternative
If you're working with an existing system that already uses SMTP (like Nodemailer), you don't need to switch to the API. Sendkit provides SMTP credentials you can drop into any SMTP-based setup:
import nodemailer from 'nodemailer';
const transport = nodemailer.createTransport({
host: 'smtp.sendkit.com',
port: 587,
auth: {
user: 'sendkit',
pass: 'sk_live_your_api_key',
},
});
const sendViaSMTP = async () => {
await transport.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Order shipped',
text: 'Your order has shipped.',
});
};The API is the better choice for new integrations. It's faster (no SMTP handshake), gives you richer responses, and supports features like attachments and tagging more cleanly. But SMTP works fine when you need backward compatibility.
Wrapping up
That covers the core of sending transactional email with Node.js. Install the SDK, send emails with the emails.send method, handle delivery events via webhooks, and build retry logic around your send calls.
The main thing that trips people up isn't the sending — it's what comes after. Monitor your bounces, handle complaints, and don't ignore webhook events. Good deliverability is maintained, not assumed.
Share this article