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:
- A Postkit account with a verified domain
- An API key -- see quickstart
- Node.js 18+ and Express 4 or 5
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
- Sending email guide -- all sending features (batch, scheduling, templates, attachments)
- Webhook integration -- event types, payload format, retry behavior
- API Reference -- full endpoint documentation