Transactional

Handle Webhooks

Learn how to receive and process webhook events from Transactional. Track email opens, clicks, bounces, and more in real-time.

Intermediate
20 minutes
webhooks
events
tracking
real-time

Prerequisites

  • Node.js 18+ installed
  • Transactional account with API key
  • Public HTTPS endpoint for webhook delivery
CODE

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 TypeDescriptionWhen It Fires
email.sentEmail accepted for deliveryImmediately after API call
email.deliveredEmail delivered to recipient serverSeconds to minutes after send
email.openedRecipient opened the emailWhen tracking pixel loads
email.clickedRecipient clicked a linkWhen tracked link is clicked
email.bouncedEmail could not be deliveredUsually within minutes
email.complainedRecipient marked as spamWhen ISP reports complaint
email.unsubscribedRecipient clicked unsubscribeWhen 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 settings

Webhook 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');
  });
});

Ready to Build?

Sign up for free and start sending emails today.