Handle Webhooks
Learn how to receive and process webhook events from Transactional. Track email opens, clicks, bounces, and more in real-time.
Prerequisites
- Node.js 18+ installed
- Transactional account with API key
- Public HTTPS endpoint for webhook delivery
Full Example
import { Transactional, WebhookEvent } from '@transactional/sdk';
import express from 'express';
const app = express();
const client = new Transactional({
apiKey: process.env.TRANSACTIONAL_API_KEY!,
});
// Webhook secret for signature verification
const WEBHOOK_SECRET = process.env.TRANSACTIONAL_WEBHOOK_SECRET!;
// Raw body is needed for signature verification
app.post('/webhooks/transactional', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-transactional-signature'] as string;
const timestamp = req.headers['x-transactional-timestamp'] as string;
// Verify webhook signature
const isValid = client.webhooks.verify({
payload: req.body,
signature,
timestamp,
secret: WEBHOOK_SECRET,
});
if (!isValid) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse the event
const event = JSON.parse(req.body.toString()) as WebhookEvent;
try {
await handleWebhookEvent(event);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook handling error:', error);
res.status(500).json({ error: 'Processing failed' });
}
});
async function handleWebhookEvent(event: WebhookEvent) {
console.log(`Received event: ${event.type}`);
switch (event.type) {
case 'email.delivered':
await handleDelivered(event.data);
break;
case 'email.opened':
await handleOpened(event.data);
break;
case 'email.clicked':
await handleClicked(event.data);
break;
case 'email.bounced':
await handleBounced(event.data);
break;
case 'email.complained':
await handleComplained(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
async function handleDelivered(data: WebhookEvent['data']) {
console.log(`Email ${data.emailId} delivered to ${data.recipient}`);
// Update your database, trigger notifications, etc.
}
async function handleOpened(data: WebhookEvent['data']) {
console.log(`Email ${data.emailId} opened by ${data.recipient}`);
// Track engagement, update analytics
}
async function handleClicked(data: WebhookEvent['data']) {
console.log(`Link clicked in ${data.emailId}: ${data.url}`);
// Track click-through, attribute conversions
}
async function handleBounced(data: WebhookEvent['data']) {
console.log(`Email ${data.emailId} bounced: ${data.bounceType}`);
if (data.bounceType === 'hard') {
// Remove from mailing list immediately
await removeFromMailingList(data.recipient);
}
}
async function handleComplained(data: WebhookEvent['data']) {
console.log(`Spam complaint from ${data.recipient}`);
// Unsubscribe immediately, investigate
await unsubscribeUser(data.recipient);
}
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
Understanding Webhooks
Webhooks are HTTP callbacks that notify your application when events occur. Instead of polling our API for updates, we push events to your server in real-time.
Available Events
| Event Type | Description | When It Fires |
|---|---|---|
email.sent | Email accepted for delivery | Immediately after API call |
email.delivered | Email delivered to recipient server | Seconds to minutes after send |
email.opened | Recipient opened the email | When tracking pixel loads |
email.clicked | Recipient clicked a link | When tracked link is clicked |
email.bounced | Email could not be delivered | Usually within minutes |
email.complained | Recipient marked as spam | When ISP reports complaint |
email.unsubscribed | Recipient clicked unsubscribe | When unsubscribe link clicked |
Webhook Security
Signature Verification
Always verify webhook signatures to ensure requests come from Transactional:
const isValid = client.webhooks.verify({
payload: rawBody, // Raw request body as string
signature: signatureHeader, // X-Transactional-Signature header
timestamp: timestampHeader, // X-Transactional-Timestamp header
secret: webhookSecret, // Your webhook secret
});Replay Attack Prevention
The timestamp header prevents replay attacks. Reject events older than 5 minutes:
const eventTime = parseInt(timestamp);
const currentTime = Math.floor(Date.now() / 1000);
const fiveMinutes = 5 * 60;
if (Math.abs(currentTime - eventTime) > fiveMinutes) {
return res.status(400).json({ error: 'Event too old' });
}Best Practices
1. Respond Quickly
Return a 2xx status code within 5 seconds. If processing takes longer, acknowledge receipt and process asynchronously:
app.post('/webhooks', async (req, res) => {
// Acknowledge immediately
res.status(200).json({ received: true });
// Process in background
processEventAsync(req.body);
});2. Handle Retries
We retry failed webhook deliveries with exponential backoff. Your handler should be idempotent:
async function handleBounce(data: BouncedEventData) {
// Use upsert to handle duplicate events
await db.bounces.upsert({
where: { emailId: data.emailId },
create: { ...data },
update: { ...data },
});
}3. Use a Queue for Reliability
For production, queue events for processing rather than handling inline:
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhooks');
app.post('/webhooks', async (req, res) => {
await webhookQueue.add('process', req.body);
res.status(200).json({ received: true });
});4. Monitor Webhook Health
Track webhook processing success and latency:
async function handleWebhookEvent(event: WebhookEvent) {
const startTime = Date.now();
try {
await processEvent(event);
metrics.increment('webhooks.processed', { type: event.type });
} catch (error) {
metrics.increment('webhooks.failed', { type: event.type });
throw error;
} finally {
metrics.timing('webhooks.duration', Date.now() - startTime);
}
}Testing Webhooks
Local Development
Use a tunneling service like ngrok to expose your local server:
ngrok http 3000
# Use the https://xxx.ngrok.io URL in your webhook settingsWebhook Test Events
Send test events from the dashboard or API:
await client.webhooks.test({
url: 'https://your-app.com/webhooks',
eventType: 'email.bounced',
});Unit Testing
import { describe, it, expect } from 'vitest';
describe('webhook handler', () => {
it('handles bounce events', async () => {
const event = {
type: 'email.bounced',
data: {
emailId: 'msg_123',
recipient: 'bounced@example.com',
bounceType: 'hard',
},
};
await handleWebhookEvent(event);
const user = await db.users.findByEmail('bounced@example.com');
expect(user.emailStatus).toBe('bounced');
});
});