Webhooks
Receive real-time notifications about email events like deliveries, opens, clicks, and bounces via HTTP webhooks.
How Webhooks Work
Event Occurs
Email is delivered, opened, clicked, etc.
We Send POST
HTTP POST to your endpoint
You Verify
Validate signature for security
Process Event
Update your database, trigger actions
Event Types
| Event | Description |
|---|---|
email.sent | Email was sent to recipient |
email.delivered | Email was delivered to recipient's inbox |
email.opened | Recipient opened the email |
email.clicked | Recipient clicked a link in the email |
email.bounced | Email bounced (hard or soft) |
email.complained | Recipient marked as spam |
contact.unsubscribed | Contact unsubscribed from emails |
Creating a Webhook
Create a webhook endpoint to receive events:
curl -X POST https://www.unosend.co/api/v1/webhooks \
-H "Authorization: Bearer un_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/api/webhooks/unosend",
"events": [
"email.delivered",
"email.opened",
"email.clicked",
"email.bounced",
"email.complained"
]
}'Response
{
"id": "whk_xxxxxxxxxxxxxxxx",
"url": "https://yourapp.com/api/webhooks/unosend",
"events": [
"email.delivered",
"email.opened",
"email.clicked",
"email.bounced",
"email.complained"
],
"signing_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxx",
"created_at": "2024-01-15T10:30:00Z"
}Important: Store the signing_secret securely - you'll need it to verify webhooks.
Webhook Payload
Each webhook request includes the following structure:
{
"id": "evt_xxxxxxxxxxxxxxxx",
"type": "email.delivered",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"email_id": "eml_xxxxxxxxxxxxxxxx",
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome to Our Platform",
"tags": {
"campaign": "onboarding"
}
}
}Click Event Example
{
"id": "evt_xxxxxxxxxxxxxxxx",
"type": "email.clicked",
"created_at": "2024-01-15T11:45:00Z",
"data": {
"email_id": "eml_xxxxxxxxxxxxxxxx",
"to": "user@example.com",
"link": "https://yourdomain.com/pricing",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.1"
}
}Bounce Event Example
{
"id": "evt_xxxxxxxxxxxxxxxx",
"type": "email.bounced",
"created_at": "2024-01-15T10:32:00Z",
"data": {
"email_id": "eml_xxxxxxxxxxxxxxxx",
"to": "invalid@example.com",
"bounce_type": "hard",
"bounce_reason": "User unknown"
}
}Verifying Webhook Signatures
Important: Always verify webhook signatures to ensure requests are from Unosend.
Each webhook request includes a signature in the X-Unosend-Signature header:
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expectedSignature}`)
);
}Complete Webhook Handler
Next.js App Router
import { NextResponse } from 'next/server';
import crypto from 'crypto';
function verifySignature(payload: string, signature: string): boolean {
const secret = process.env.UNOSEND_WEBHOOK_SECRET!;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return signature === `sha256=${expected}`;
}
export async function POST(request: Request) {
const payload = await request.text();
const signature = request.headers.get('X-Unosend-Signature');
if (!signature || !verifySignature(payload, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(payload);
switch (event.type) {
case 'email.delivered':
console.log('Email delivered:', event.data.email_id);
// Update email status in database
break;
case 'email.opened':
console.log('Email opened:', event.data.email_id);
// Track email open
break;
case 'email.clicked':
console.log('Link clicked:', event.data.link);
// Track link click
break;
case 'email.bounced':
console.log('Email bounced:', event.data.to);
// Mark contact as bounced
break;
case 'email.complained':
console.log('Spam complaint:', event.data.to);
// Unsubscribe user
break;
default:
console.log('Unhandled event type:', event.type);
}
return NextResponse.json({ received: true });
}Python Flask
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = 'whsec_your_secret'
def verify_signature(payload: bytes, signature: str) -> bool:
expected = hmac.new(
WEBHOOK_SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
return signature == f'sha256={expected}'
@app.route('/api/webhooks/unosend', methods=['POST'])
def handle_webhook():
payload = request.get_data()
signature = request.headers.get('X-Unosend-Signature')
if not signature or not verify_signature(payload, signature):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
if event['type'] == 'email.delivered':
print(f"Email delivered: {event['data']['email_id']}")
elif event['type'] == 'email.bounced':
print(f"Email bounced: {event['data']['to']}")
return jsonify({'received': True})Retry Logic
If your endpoint returns a non-2xx status code, we'll retry the webhook:
- • 1st retry: 1 minute after failure
- • 2nd retry: 5 minutes after failure
- • 3rd retry: 30 minutes after failure
- • 4th retry: 2 hours after failure
- • 5th retry: 8 hours after failure
After 5 failed attempts, the webhook is marked as failed and won't be retried.
Managing Webhooks
List Webhooks
curl https://www.unosend.co/api/v1/webhooks \
-H "Authorization: Bearer un_your_api_key"Update a Webhook
curl -X PATCH https://www.unosend.co/api/v1/webhooks/whk_xxxxxxxx \
-H "Authorization: Bearer un_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"events": ["email.delivered", "email.bounced"]
}'Delete a Webhook
curl -X DELETE https://www.unosend.co/api/v1/webhooks/whk_xxxxxxxx \
-H "Authorization: Bearer un_your_api_key"Best Practices
Always verify signatures to ensure webhooks are from Unosend
Return 200 quickly and process events asynchronously
Handle duplicates - use event ID for idempotency
Log all events for debugging and auditing
Use HTTPS for your webhook endpoint