Setting Up Webhooks

Learn how to set up and handle webhooks for real-time event notifications.

Setting Up Webhooks

Webhooks allow you to receive real-time notifications when events occur in your Transactional account. This guide covers setting up, securing, and handling webhooks.

Overview

When you configure a webhook, Transactional sends HTTP POST requests to your specified URL whenever subscribed events occur. This enables real-time integrations without polling the API.

Creating a Webhook

Via Dashboard

  1. Navigate to Settings > Webhooks
  2. Click Add Webhook
  3. Enter your endpoint URL
  4. Select the events you want to receive
  5. Click Create

Via API

import { Transactional } from 'transactional-sdk';
 
const client = new Transactional({
  apiKey: process.env.TRANSACTIONAL_API_KEY,
});
 
const webhook = await client.webhooks.create({
  url: 'https://your-app.com/webhooks/transactional',
  events: [
    'email.delivered',
    'email.bounced',
    'email.opened',
    'email.clicked',
    'sms.delivered',
    'sms.failed',
    'form.submitted',
  ],
});
 
console.log('Webhook ID:', webhook.id);
console.log('Signing Secret:', webhook.signingSecret);

Available Events

Email Events

EventDescription
email.queuedEmail accepted and queued
email.sentEmail sent to recipient server
email.deliveredEmail delivered to inbox
email.bouncedEmail bounced (hard or soft)
email.openedRecipient opened the email
email.clickedRecipient clicked a link
email.unsubscribedRecipient unsubscribed
email.spam_complaintMarked as spam

SMS Events

EventDescription
sms.queuedSMS queued for sending
sms.sentSMS sent to carrier
sms.deliveredSMS delivered to recipient
sms.failedSMS delivery failed
sms.inboundInbound SMS received

Form Events

EventDescription
form.submittedForm submission received
form.partialPartial submission saved

Webhook Payload

All webhooks follow this structure:

{
  "id": "evt_abc123",
  "type": "email.delivered",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "messageId": "msg_xyz789",
    "recipient": "user@example.com",
    "subject": "Welcome!",
    "deliveredAt": "2024-01-15T10:30:00Z"
  }
}

Handling Webhooks

Basic Handler (Express.js)

import express from 'express';
import crypto from 'crypto';
 
const app = express();
 
// Use raw body for signature verification
app.post('/webhooks/transactional',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-transactional-signature'];
    const timestamp = req.headers['x-transactional-timestamp'];
    const payload = req.body.toString();
 
    // Verify signature
    if (!verifySignature(payload, signature, timestamp)) {
      return res.status(401).send('Invalid signature');
    }
 
    const event = JSON.parse(payload);
 
    // Process the event
    switch (event.type) {
      case 'email.delivered':
        handleEmailDelivered(event.data);
        break;
      case 'email.bounced':
        handleEmailBounced(event.data);
        break;
      case 'sms.delivered':
        handleSmsDelivered(event.data);
        break;
      case 'form.submitted':
        handleFormSubmission(event.data);
        break;
      default:
        console.log('Unhandled event type:', event.type);
    }
 
    // Respond quickly with 200 OK
    res.sendStatus(200);
  }
);
 
function verifySignature(
  payload: string,
  signature: string,
  timestamp: string
): boolean {
  const signingSecret = process.env.WEBHOOK_SIGNING_SECRET;
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', signingSecret)
    .update(signedPayload)
    .digest('hex');
 
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

Using the SDK Webhook Helper

import { verifyWebhookSignature } from 'transactional-sdk';
 
app.post('/webhooks/transactional',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      const event = verifyWebhookSignature(
        req.body.toString(),
        req.headers['x-transactional-signature'],
        req.headers['x-transactional-timestamp'],
        process.env.WEBHOOK_SIGNING_SECRET
      );
 
      // Event is verified and parsed
      handleEvent(event);
      res.sendStatus(200);
    } catch (error) {
      console.error('Webhook verification failed:', error);
      res.status(401).send('Invalid signature');
    }
  }
);

Event Handlers

Email Events

async function handleEmailDelivered(data: EmailDeliveredEvent) {
  const { messageId, recipient, deliveredAt } = data;
 
  // Update your database
  await db.emailLogs.update({
    where: { messageId },
    data: { status: 'DELIVERED', deliveredAt },
  });
 
  // Update analytics
  await analytics.track('Email Delivered', {
    messageId,
    recipient,
    deliveredAt,
  });
}
 
async function handleEmailBounced(data: EmailBouncedEvent) {
  const { messageId, recipient, bounceType, bounceMessage } = data;
 
  if (bounceType === 'HARD') {
    // Remove from mailing list
    await db.subscribers.update({
      where: { email: recipient },
      data: { status: 'BOUNCED', bounceReason: bounceMessage },
    });
  }
 
  // Log the bounce
  await db.emailLogs.update({
    where: { messageId },
    data: { status: 'BOUNCED', bounceType, bounceMessage },
  });
}
 
async function handleEmailOpened(data: EmailOpenedEvent) {
  const { messageId, recipient, openedAt, userAgent, ipAddress } = data;
 
  // Track open event
  await db.emailEvents.create({
    data: {
      messageId,
      eventType: 'OPENED',
      timestamp: openedAt,
      metadata: { userAgent, ipAddress },
    },
  });
}

SMS Events

async function handleSmsDelivered(data: SmsDeliveredEvent) {
  const { messageId, to, deliveredAt } = data;
 
  await db.smsLogs.update({
    where: { messageId },
    data: { status: 'DELIVERED', deliveredAt },
  });
}
 
async function handleSmsFailed(data: SmsFailedEvent) {
  const { messageId, to, errorCode, errorMessage } = data;
 
  await db.smsLogs.update({
    where: { messageId },
    data: { status: 'FAILED', errorCode, errorMessage },
  });
 
  // Alert on failures
  if (errorCode === 'CARRIER_REJECTED') {
    await alerting.notify('SMS delivery failed', { messageId, to, errorMessage });
  }
}

Form Events

async function handleFormSubmission(data: FormSubmittedEvent) {
  const { formId, submissionId, data: formData } = data;
 
  // Store submission
  await db.submissions.create({
    data: {
      formId,
      submissionId,
      data: formData,
      receivedAt: new Date(),
    },
  });
 
  // Send notification email
  await client.emails.send({
    from: 'noreply@yourapp.com',
    to: 'sales@yourapp.com',
    subject: 'New Form Submission',
    html: `<p>New submission received:</p><pre>${JSON.stringify(formData, null, 2)}</pre>`,
  });
 
  // Add to CRM
  await crm.createLead({
    email: formData.email,
    name: formData.name,
    source: 'Website Form',
  });
}

Best Practices

1. Respond Quickly

Return a 200 response immediately, then process asynchronously:

app.post('/webhooks/transactional', (req, res) => {
  // Respond immediately
  res.sendStatus(200);
 
  // Process asynchronously
  processWebhook(req.body).catch(console.error);
});
 
async function processWebhook(event) {
  // Long-running processing here
}

2. Use a Queue

For high-volume webhooks, use a message queue:

import { Queue } from 'bullmq';
 
const webhookQueue = new Queue('webhooks');
 
app.post('/webhooks/transactional', async (req, res) => {
  // Verify signature
  const event = verifyWebhookSignature(req.body, req.headers);
 
  // Add to queue
  await webhookQueue.add('process', event);
 
  res.sendStatus(200);
});
 
// Worker processes events
const worker = new Worker('webhooks', async (job) => {
  const event = job.data;
  await handleEvent(event);
});

3. Handle Retries

Transactional retries failed webhooks with exponential backoff:

  • 1st retry: 30 seconds
  • 2nd retry: 5 minutes
  • 3rd retry: 30 minutes
  • 4th retry: 2 hours
  • 5th retry: 24 hours

Ensure your handlers are idempotent:

async function handleEmailDelivered(data: EmailDeliveredEvent) {
  const { messageId } = data;
 
  // Check if already processed
  const existing = await db.emailEvents.findUnique({
    where: { messageId_eventType: { messageId, eventType: 'DELIVERED' } },
  });
 
  if (existing) {
    console.log('Event already processed:', messageId);
    return;
  }
 
  // Process the event
  await db.emailEvents.create({
    data: { messageId, eventType: 'DELIVERED', ... },
  });
}

4. Monitor Webhook Health

Check webhook delivery status:

const webhook = await client.webhooks.get(webhookId);
 
console.log('Success rate:', webhook.stats.successRate);
console.log('Last delivery:', webhook.stats.lastDeliveryAt);
console.log('Failed count:', webhook.stats.failedCount);

Testing Webhooks

Local Development with ngrok

# Start ngrok tunnel
ngrok http 3000
 
# Use the ngrok URL for your webhook
# https://abc123.ngrok.io/webhooks/transactional

Test Events

Send a test event from the dashboard or API:

await client.webhooks.test(webhookId, {
  eventType: 'email.delivered',
});

Next Steps