Webhooks

Receive real-time notifications via webhooks.

Overview

Webhooks allow your application to receive real-time HTTP callbacks when events occur in Transactional. Instead of polling for changes, webhooks push data to your server as events happen.


How Webhooks Work

  1. You register a webhook URL in the dashboard or via API
  2. You select which events to subscribe to
  3. When an event occurs, we send an HTTP POST to your URL
  4. Your server processes the payload and returns 200 OK
  5. If delivery fails, we retry with exponential backoff

Webhook Payload Format

All webhook payloads follow a consistent structure:

{
  "id": "evt_abc123xyz",
  "type": "email.delivered",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "data": {
    "messageId": "msg_xyz789",
    "to": "recipient@example.com",
    "subject": "Welcome!",
    "deliveredAt": "2024-01-15T10:30:00.000Z"
  }
}
FieldTypeDescription
idstringUnique event identifier
typestringEvent type (e.g., email.delivered)
timestampstringISO 8601 timestamp
dataobjectEvent-specific payload

Event Types

Email Events

EventDescription
email.sentEmail accepted for delivery
email.deliveredEmail delivered to recipient's server
email.openedRecipient opened the email
email.clickedRecipient clicked a link
email.bouncedEmail bounced (hard or soft)
email.spam_complaintRecipient marked as spam
email.unsubscribedRecipient unsubscribed

SMS Events

EventDescription
sms.sentSMS sent to carrier
sms.deliveredSMS delivered to recipient
sms.failedSMS delivery failed
sms.inboundInbound SMS received
sms.opted_outRecipient opted out

Forms Events

EventDescription
form.submittedForm submission received
form.partialPartial submission saved

Support Events

EventDescription
conversation.createdNew conversation started
conversation.repliedNew message in conversation
conversation.closedConversation closed
conversation.assignedConversation assigned to agent

Auth Events

EventDescription
user.createdNew user registered
user.deletedUser account deleted
user.blockedUser account blocked
login.successSuccessful login
login.failedFailed login attempt
mfa.enrolledMFA factor enrolled
password.changedPassword updated
password.resetPassword reset completed

Calendar Events

EventDescription
booking.createdNew booking scheduled
booking.cancelledBooking cancelled
booking.rescheduledBooking time changed
booking.reminderReminder sent

Webhook Security

Signature Verification

Every webhook request includes a signature header for verification:

X-Transactional-Signature: sha256=abc123...

The signature is an HMAC-SHA256 hash of the raw request body using your webhook secret.

TypeScript Verification

import crypto from 'crypto';
 
function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
 
  const provided = signature.replace('sha256=', '');
 
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(provided, 'hex')
  );
}
 
// Express.js example
app.post('/webhooks/transactional', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-transactional-signature'];
  const payload = req.body.toString();
 
  if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
 
  const event = JSON.parse(payload);
  console.log(`Received event: ${event.type}`);
 
  res.status(200).send('OK');
});

Python Verification

import hmac
import hashlib
from flask import Flask, request
 
app = Flask(__name__)
 
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
 
    provided = signature.replace('sha256=', '')
 
    return hmac.compare_digest(expected, provided)
 
 
@app.route('/webhooks/transactional', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Transactional-Signature', '')
    payload = request.get_data()
 
    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401
 
    event = request.get_json()
    print(f"Received event: {event['type']}")
 
    return 'OK', 200

Managing Webhooks

Create Webhook via API

curl -X POST https://api.usetransactional.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks",
    "events": ["email.delivered", "email.bounced"],
    "active": true
  }'

TypeScript SDK

// Create webhook
const webhook = await client.webhooks.create({
  url: 'https://your-app.com/webhooks',
  events: ['email.delivered', 'email.bounced', 'email.spam_complaint'],
  active: true,
});
 
// List webhooks
const webhooks = await client.webhooks.list();
 
// Update webhook
await client.webhooks.update(webhook.id, {
  events: ['email.delivered', 'email.bounced', 'email.opened'],
});
 
// Delete webhook
await client.webhooks.delete(webhook.id);
 
// Test webhook (sends test event)
await client.webhooks.test(webhook.id);

Python SDK

# Create webhook
webhook = client.webhooks.create(
    url="https://your-app.com/webhooks",
    events=["email.delivered", "email.bounced", "email.spam_complaint"],
    active=True,
)
 
# List webhooks
webhooks = client.webhooks.list()
 
# Update webhook
client.webhooks.update(
    webhook.id,
    events=["email.delivered", "email.bounced", "email.opened"],
)
 
# Delete webhook
client.webhooks.delete(webhook.id)
 
# Test webhook
client.webhooks.test(webhook.id)

Retry Policy

If your webhook endpoint fails to respond with 2xx, we retry:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
68 hours
724 hours

After 7 failed attempts, the webhook is marked as failed. You'll receive an email notification, and the webhook will be automatically disabled.

Checking Delivery Status

// List recent deliveries
const deliveries = await client.webhooks.listDeliveries(webhookId, {
  count: 50,
  status: 'FAILED', // or 'SUCCESS', 'PENDING'
});
 
// Retry a specific delivery
await client.webhooks.retryDelivery(webhookId, deliveryId);

Best Practices

1. Return 200 Quickly

Process webhooks asynchronously to return 200 within 30 seconds:

app.post('/webhooks', async (req, res) => {
  // Verify signature
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid');
  }
 
  // Acknowledge receipt immediately
  res.status(200).send('OK');
 
  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});
 
async function processWebhookAsync(event) {
  switch (event.type) {
    case 'email.bounced':
      await handleBounce(event.data);
      break;
    // ... other handlers
  }
}

2. Handle Duplicates

Webhooks may be delivered more than once. Use the event ID for idempotency:

const processedEvents = new Set();
 
async function handleWebhook(event) {
  // Check if already processed
  if (await redis.sismember('processed_webhooks', event.id)) {
    return; // Already processed
  }
 
  // Process the event
  await processEvent(event);
 
  // Mark as processed (with TTL)
  await redis.setex(`webhook:${event.id}`, 86400, 'processed');
}

3. Validate Event Data

Always validate the event data before processing:

import { z } from 'zod';
 
const EmailDeliveredSchema = z.object({
  messageId: z.string(),
  to: z.string().email(),
  deliveredAt: z.string().datetime(),
});
 
function handleEmailDelivered(data: unknown) {
  const result = EmailDeliveredSchema.safeParse(data);
  if (!result.success) {
    console.error('Invalid webhook data:', result.error);
    return;
  }
 
  // Process validated data
  await markDelivered(result.data.messageId);
}

4. Use Event-Specific Endpoints

For better organization, use different endpoints per module:

// Email webhooks
app.post('/webhooks/email', handleEmailWebhook);
 
// SMS webhooks
app.post('/webhooks/sms', handleSmsWebhook);
 
// Forms webhooks
app.post('/webhooks/forms', handleFormsWebhook);

5. Log Everything

Keep detailed logs for debugging:

async function handleWebhook(req, res) {
  const event = req.body;
 
  console.log({
    type: 'webhook_received',
    eventId: event.id,
    eventType: event.type,
    timestamp: new Date().toISOString(),
  });
 
  try {
    await processEvent(event);
 
    console.log({
      type: 'webhook_processed',
      eventId: event.id,
      success: true,
    });
  } catch (error) {
    console.error({
      type: 'webhook_error',
      eventId: event.id,
      error: error.message,
    });
    throw error;
  }
}

Testing Webhooks

Local Development

Use ngrok or similar to expose your local server:

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

Test Events

Send test events from the dashboard or API:

// Send test event
await client.webhooks.test(webhookId);
 
// Or use the dashboard:
// Dashboard > Webhooks > Select webhook > Send Test

Sample Payloads

Use these sample payloads for testing:

Email Delivered:

{
  "id": "evt_test_001",
  "type": "email.delivered",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "data": {
    "messageId": "msg_abc123",
    "to": "test@example.com",
    "subject": "Test Email",
    "deliveredAt": "2024-01-15T10:30:00.000Z"
  }
}

SMS Delivered:

{
  "id": "evt_test_002",
  "type": "sms.delivered",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "data": {
    "messageId": "sms_xyz789",
    "to": "+14155551234",
    "deliveredAt": "2024-01-15T10:30:00.000Z"
  }
}

Form Submitted:

{
  "id": "evt_test_003",
  "type": "form.submitted",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "data": {
    "formId": "form_123",
    "submissionId": "sub_456",
    "fields": {
      "name": "John Doe",
      "email": "john@example.com"
    }
  }
}

Webhook IP Allowlist

If you need to allowlist our IPs, webhook requests come from:

52.45.123.100
52.45.123.101
52.45.123.102
34.201.100.50
34.201.100.51

Note: IPs may change. We recommend using signature verification instead of IP allowlisting.


Troubleshooting

Common Issues

Webhook not receiving events:

  1. Check the webhook is active in the dashboard
  2. Verify the URL is publicly accessible
  3. Ensure you're subscribed to the correct events
  4. Check for firewall/security group blocking

Invalid signature errors:

  1. Use the raw request body (not parsed JSON)
  2. Ensure you're using the correct webhook secret
  3. Check for encoding issues

Timeouts:

  1. Return 200 immediately, process async
  2. Check your server's response time
  3. Consider using a queue for processing

Debugging

View webhook delivery logs:

const deliveries = await client.webhooks.listDeliveries(webhookId, {
  status: 'FAILED',
  count: 10,
});
 
for (const delivery of deliveries) {
  console.log({
    eventType: delivery.eventType,
    status: delivery.status,
    statusCode: delivery.responseCode,
    error: delivery.error,
    attemptedAt: delivery.attemptedAt,
  });
}