postkit
Integrations

Express

Send transactional emails from your Express.js application with Postkit

Send transactional emails from your Express application using the Postkit REST API. This guide covers setup, sending patterns, webhook verification, and error handling.

Prerequisites:

Setup

Set up environment variables

Create a .env file with your Postkit API key and load it with dotenv:

POSTKIT_API_KEY=pk_live_abc123...
npm install dotenv
// At the top of your entry file
require('dotenv').config();

Create an email service module

Create a reusable module that wraps the Postkit REST API.

// services/postkit.js

async function sendEmail({ to, subject, html, from }) {
  const response = await fetch('https://api.postkit.eu/v1/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.POSTKIT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: from ?? 'Your App <noreply@yourdomain.eu>',
      to,
      subject,
      html,
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error?.message ?? 'Failed to send email');
  }

  return response.json();
}

module.exports = { sendEmail };
// services/postkit.mjs

export async function sendEmail({ to, subject, html, from }) {
  const response = await fetch('https://api.postkit.eu/v1/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.POSTKIT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: from ?? 'Your App <noreply@yourdomain.eu>',
      to,
      subject,
      html,
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error?.message ?? 'Failed to send email');
  }

  return response.json();
}

Sending from route handlers

Use the email service in your Express routes:

const express = require('express');
const { sendEmail } = require('./services/postkit');

const app = express();
app.use(express.json());

app.post('/api/contact', async (req, res) => {
  const { email, name, message } = req.body;

  try {
    const result = await sendEmail({
      to: ['support@yourdomain.eu'],
      subject: `Contact: ${name}`,
      html: `<p>From: ${name} (${email})</p><p>${message}</p>`,
    });
    res.status(201).json(result);
  } catch (error) {
    console.error('Email send failed:', error.message);
    res.status(500).json({ error: { message: 'Failed to send email' } });
  }
});

Error handling middleware

Use an async wrapper and global error handler for cleaner route definitions:

// Reusable async wrapper
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.post('/api/contact', asyncHandler(async (req, res) => {
  const { email, name, message } = req.body;

  const result = await sendEmail({
    to: ['support@yourdomain.eu'],
    subject: `Contact: ${name}`,
    html: `<p>From: ${name} (${email})</p><p>${message}</p>`,
  });
  res.status(201).json(result);
}));

// Global error handler
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: { message: 'Internal server error' } });
});

Webhook verification

Verify incoming Postkit webhooks using the Standard Webhooks HMAC-SHA256 signature.

Use express.raw() for the webhook route to get the raw body for HMAC verification. Do not use express.json() on this route -- it parses the body and changes the bytes, which breaks signature verification.

const crypto = require('crypto');

// Use raw body for signature verification
app.post('/webhooks/postkit', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString();
  const signature = req.headers['webhook-signature'] ?? '';
  const msgId = req.headers['webhook-id'] ?? '';
  const timestamp = req.headers['webhook-timestamp'] ?? '';

  const secret = process.env.POSTKIT_WEBHOOK_SECRET;
  const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64');
  const signedContent = `${msgId}.${timestamp}.${body}`;
  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  const signatures = signature.split(' ');
  const isValid = signatures.some((sig) => sig.replace('v1,', '') === expected);

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(body);

  // Handle different event types
  switch (event.type) {
    case 'email.delivered':
      // Update delivery status in your database
      break;
    case 'email.bounced':
      // Handle bounce
      break;
    case 'email.complained':
      // Handle spam complaint
      break;
  }

  res.json({ received: true });
});

The signing secret starts with whsec_. Strip this prefix and base64-decode to get the HMAC key bytes. See the Webhooks guide for a detailed explanation of the verification algorithm.

Next steps