How to Send Emails from Node.js / Express
A practical, step-by-step tutorial on sending transactional emails and processing real-time delivery webhooks in Express.js with the Sently SDK.
💡 Summary: Sending Transactional Emails in Express.js
To send transactional emails in Node.js, initialize the Sently client with new Sently(process.env.SENTLY_API_KEY), call await sently.emails.send() in your API route handler, and optionally attach an Express webhook endpoint verifying the HMAC-SHA256 signature via verifyWebhook().
Node.js and Express remain the workhorse of backend web APIs. When users sign up, request password resets, or make purchases, your server needs to dispatch emails reliably and listen for delivery updates.
In this guide, you will learn how to set up the sently-sdk in an Express application, send transactional welcome emails, and safely handle verified webhooks for delivery tracking.
1 Install Required Packages
Install Express and the Sently SDK in your project:
npm install express sently-sdk dotenv
npm install --save-dev typescript @types/express @types/node2 Set Up Environment Variables
Store your Sently credentials securely in your .env file. You can generate an API key and webhook secret inside your Sently dashboard:
# .env
PORT=3000
SENTLY_API_KEY=snt_live_your_sently_key_here
SENTLY_BASE_URL=https://sently.in
SENTLY_WEBHOOK_SECRET=whsec_your_webhook_signing_secret3 Create the Express POST Route
Create an Express endpoint that receives a signup request and calls sently.emails.send() to deliver a styled transactional welcome email:
// server.ts
import express, { Request, Response } from 'express';
import dotenv from 'dotenv';
import { Sently, SentlyError } from 'sently-sdk';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
// JSON parser for regular API endpoints
app.use(express.json());
// Initialize Sently client
const sently = new Sently(process.env.SENTLY_API_KEY!, {
baseUrl: process.env.SENTLY_BASE_URL || 'https://sently.in',
});
// POST route: Send Welcome Email upon user registration
app.post('/api/auth/register', async (req: Request, res: Response) => {
const { email, name } = req.body;
if (!email || !name) {
return res.status(400).json({ error: 'Name and email are required.' });
}
try {
// 1. Save user to database (your own logic)...
// 2. Dispatch welcome email via Sently
const emailResult = await sently.emails.send({
from: 'Acme <[email protected]>',
to: email,
subject: `Welcome to Acme, ${name}!`,
html: `
<div style="font-family: sans-serif; line-height: 1.6; max-width: 600px; margin: auto; padding: 20px;">
<h1 style="color: #1a1a1a;">Welcome to Acme, ${name}! 🎉</h1>
<p>We're thrilled to have you on board. Your account has been created and is ready to explore.</p>
<p>
<a href="https://yourdomain.com/dashboard"
style="display: inline-block; padding: 12px 24px; background-color: #1d1b16; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
Go to Your Dashboard
</a>
</p>
<hr style="border: none; border-top: 1px solid #eaeaea; margin: 30px 0;" />
<p style="font-size: 13px; color: #777;">Need help getting started? Reply to this email and our team will assist you.</p>
</div>
`,
text: `Welcome to Acme, ${name}! Go to your dashboard at https://yourdomain.com/dashboard to get started.`,
category: 'transactional',
metadata: {
userId: 'usr_12345',
source: 'registration-flow',
},
});
console.log(`Welcome email dispatched: id=${emailResult.id}, status=${emailResult.status}`);
return res.status(201).json({
message: 'User registered successfully',
emailId: emailResult.id,
});
} catch (error) {
if (error instanceof SentlyError) {
console.error(`Sently error [${error.code}]: ${error.message}`);
return res.status(error.status || 500).json({
error: error.message,
code: error.code,
fields: error.fields,
});
}
console.error('Registration error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});4 Verify and Handle Webhooks
Email delivery doesn't end when the API returns queued. Sently dispatches real-time webhooks whenever an email is delivered, opened, clicked, bounced, or marked as spam.
To prevent forged webhook requests, Sently signs all payloads with HMAC-SHA256 in the sently-signature header. The SDK includes a built-in verifyWebhook() helper:
// Handling Sently Webhooks in Express
import express, { Request, Response } from 'express';
import { verifyWebhook, WebhookEvent } from 'sently-sdk';
const app = express();
/**
* CRITICAL: Sently webhook signatures are calculated over the raw request payload.
* Mount express.raw() specifically on your webhook path BEFORE standard JSON parsers.
*/
app.post(
'/api/webhooks/sently',
express.raw({ type: 'application/json' }),
async (req: Request, res: Response) => {
const signature = req.headers['sently-signature'] as string;
const webhookSecret = process.env.SENTLY_WEBHOOK_SECRET!;
if (!signature) {
return res.status(400).send('Missing sently-signature header');
}
let event: WebhookEvent;
try {
const rawPayload = req.body.toString('utf8');
// Verifies HMAC-SHA256 signature and checks replay timestamp tolerance (default 300s)
event = verifyWebhook(rawPayload, signature, webhookSecret);
} catch (err: any) {
console.error('Webhook verification failed:', err.message);
return res.status(401).send(`Webhook verification failed: ${err.message}`);
}
// Process event based on type
console.log(`Received verified Sently event [${event.type}] ID=${event.id}`);
switch (event.type) {
case 'email.delivered':
console.log(`Email ${event.data.email_id} was successfully delivered.`);
break;
case 'email.bounced':
console.warn(`Email ${event.data.email_id} bounced. Reason:`, event.data);
// Flag user's email address in your database as invalid
break;
case 'email.complained':
console.warn(`Spam complaint received for email ${event.data.email_id}.`);
// Immediately suppress future marketing or notifications to recipient
break;
case 'email.opened':
case 'email.clicked':
console.log(`Engagement event: ${event.type} on email ${event.data.email_id}`);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Return 200 OK immediately so Sently knows the event was acknowledged
return res.status(200).json({ received: true });
}
);Important Body Parser Rule: Webhook signature verification operates on the raw, unmodified string bytes of the HTTP body. Always mount express.raw({ type: 'application/json' }) on your webhook route prior to applying global express.json() middleware.
5 Batch Sending for High-Throughput
Need to notify multiple administrators or team members simultaneously? Use sently.emails.sendBatch() to send up to 100 emails in a single HTTP roundtrip:
// Bonus: High-throughput batch email dispatch
const batchResult = await sently.emails.sendBatch([
{
from: 'Alerts <[email protected]>',
to: '[email protected]',
subject: 'Daily Backup Completed',
text: 'All databases backed up cleanly.',
},
{
from: 'Alerts <[email protected]>',
to: '[email protected]',
subject: 'Daily Backup Completed',
text: 'All databases backed up cleanly.',
},
]);
// Each email is queued independently
for (const item of batchResult.results) {
if ('error' in item) {
console.error('Failed recipient:', item.error.message);
} else {
console.log('Queued email ID:', item.id);
}
}6 Error Handling & Best Practices
When building production Node.js services, keep these recommendations in mind:
- Catch
SentlyError: Inspecterror.statusanderror.codeto provide friendly error feedback to your users without exposing sensitive credentials. - Domain Verification: Ensure your sending domain (e.g.
yourdomain.com) is verified with SPF and DKIM records in your Sently dashboard. - Deduplicate Webhooks: Store processed
event.idvalues in Redis or your database to guard against duplicate webhook deliveries during network retries.