← Back to blog
·7 min read·
Paulo CastellanoPaulo Castellano

How 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.

nodejsemail-apitransactional-emailtutorial
How to send transactional email with Node.js

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/sdk

You'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.

Setting up the Sendkit SDK in a Node.js project

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.

Monitoring webhook events for email delivery tracking

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