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
- You register a webhook URL in the dashboard or via API
- You select which events to subscribe to
- When an event occurs, we send an HTTP POST to your URL
- Your server processes the payload and returns
200 OK - 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"
}
}| Field | Type | Description |
|---|---|---|
id | string | Unique event identifier |
type | string | Event type (e.g., email.delivered) |
timestamp | string | ISO 8601 timestamp |
data | object | Event-specific payload |
Event Types
Email Events
| Event | Description |
|---|---|
email.sent | Email accepted for delivery |
email.delivered | Email delivered to recipient's server |
email.opened | Recipient opened the email |
email.clicked | Recipient clicked a link |
email.bounced | Email bounced (hard or soft) |
email.spam_complaint | Recipient marked as spam |
email.unsubscribed | Recipient unsubscribed |
SMS Events
| Event | Description |
|---|---|
sms.sent | SMS sent to carrier |
sms.delivered | SMS delivered to recipient |
sms.failed | SMS delivery failed |
sms.inbound | Inbound SMS received |
sms.opted_out | Recipient opted out |
Forms Events
| Event | Description |
|---|---|
form.submitted | Form submission received |
form.partial | Partial submission saved |
Support Events
| Event | Description |
|---|---|
conversation.created | New conversation started |
conversation.replied | New message in conversation |
conversation.closed | Conversation closed |
conversation.assigned | Conversation assigned to agent |
Auth Events
| Event | Description |
|---|---|
user.created | New user registered |
user.deleted | User account deleted |
user.blocked | User account blocked |
login.success | Successful login |
login.failed | Failed login attempt |
mfa.enrolled | MFA factor enrolled |
password.changed | Password updated |
password.reset | Password reset completed |
Calendar Events
| Event | Description |
|---|---|
booking.created | New booking scheduled |
booking.cancelled | Booking cancelled |
booking.rescheduled | Booking time changed |
booking.reminder | Reminder 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', 200Managing 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 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/webhooksTest 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 TestSample 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:
- Check the webhook is active in the dashboard
- Verify the URL is publicly accessible
- Ensure you're subscribed to the correct events
- Check for firewall/security group blocking
Invalid signature errors:
- Use the raw request body (not parsed JSON)
- Ensure you're using the correct webhook secret
- Check for encoding issues
Timeouts:
- Return 200 immediately, process async
- Check your server's response time
- 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,
});
}