Webhooks
The Webhooks API allows you to receive real-time notifications when events occur in your Unsent account.
Overview
Webhooks enable your application to receive real-time notifications when specific events occur in your Unsent account. Instead of continuously polling the API to check for changes, webhooks push event data to your server as events happen, making them ideal for building reactive, event-driven applications.
Base URL
https://api.unsent.dev/v1/webhooksFeatures
Real-time notifications
Receive instant updates when events occur
Event filtering
Subscribe to specific event types you care about
Secure delivery
HMAC signature verification for secure webhooks
Automatic retries
Failed deliveries are automatically retried
Webhook testing
Test your webhook endpoints before going live
Monitoring
Track webhook delivery status and failures
Endpoints
Create Webhook
POST /v1/webhooks
Register a new webhook endpoint for your team
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
url | string (URL) | Yes | The HTTPS endpoint to receive webhook events |
eventTypes | array | Yes | Array of event types to subscribe to (see Event Types) |
description | string | No | Optional description for this webhook |
secret | string | No | Optional secret key for HMAC signature verification (min 16 characters). If not provided, one will be generated automatically |
curl -X POST https://api.unsent.dev/v1/webhooks \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourdomain.com/webhooks/unsent",
"description": "Production webhook endpoint",
"eventTypes": [
"email.sent",
"email.delivered",
"email.bounced",
"email.opened",
"email.clicked"
],
"secret": "your-secret-key-at-least-16-chars"
}'Response (200 OK):
{
"id": "wh_abc123def456",
"url": "https://yourdomain.com/webhooks/unsent",
"description": "Production webhook endpoint",
"eventTypes": [
"email.sent",
"email.delivered",
"email.bounced",
"email.opened",
"email.clicked"
],
"status": "ACTIVE",
"secret": "your-secret-key-at-least-16-chars",
"apiVersion": null,
"consecutiveFailures": 0,
"lastSuccessAt": null,
"lastFailureAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z",
"teamId": "team_xyz789",
"createdByUserId": "user_abc123"
}[!IMPORTANT] The
secretfield in the response is the only time you'll see the full secret value. Store it securely to verify webhook signatures.
List All Webhooks
GET /v1/webhooks
Retrieve all webhooks registered for your team
curl -X GET https://api.unsent.dev/v1/webhooks \
-H "Authorization: Bearer your-api-key"Response (200 OK):
[
{
"id": "wh_abc123def456",
"url": "https://yourdomain.com/webhooks/unsent",
"description": "Production webhook endpoint",
"eventTypes": [
"email.sent",
"email.delivered",
"email.bounced"
],
"status": "ACTIVE",
"secret": "wh_sec_***",
"apiVersion": null,
"consecutiveFailures": 0,
"lastSuccessAt": "2024-01-15T12:00:00Z",
"lastFailureAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z",
"teamId": "team_xyz789",
"createdByUserId": "user_abc123"
}
][!NOTE] The
secretfield is masked in list responses for security purposes.
Get Webhook by ID
GET /v1/webhooks/{id}
Retrieve detailed information about a specific webhook
Path Parameters:
id(string, required) - The webhook ID
curl -X GET https://api.unsent.dev/v1/webhooks/wh_abc123def456 \
-H "Authorization: Bearer your-api-key"Response (200 OK):
{
"id": "wh_abc123def456",
"url": "https://yourdomain.com/webhooks/unsent",
"description": "Production webhook endpoint",
"eventTypes": [
"email.sent",
"email.delivered",
"email.bounced"
],
"status": "ACTIVE",
"secret": "wh_sec_***",
"apiVersion": null,
"consecutiveFailures": 0,
"lastSuccessAt": "2024-01-15T12:00:00Z",
"lastFailureAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z",
"teamId": "team_xyz789",
"createdByUserId": "user_abc123"
}Error Responses:
- 404 Not Found - Webhook not found
{ "code": "NOT_FOUND", "message": "Webhook not found" }
Update Webhook
PATCH /v1/webhooks/{id}
Update an existing webhook configuration
Path Parameters:
id(string, required) - The webhook ID
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
url | string (URL) | No | New webhook endpoint URL |
description | string | null | No | Updated description |
eventTypes | array | No | Updated event types to subscribe to |
active | boolean | No | Set to false to pause the webhook, true to activate |
rotateSecret | boolean | No | Set to true to generate a new secret key |
secret | string | No | Manually set a new secret (min 16 characters) |
curl -X PATCH https://api.unsent.dev/v1/webhooks/wh_abc123def456 \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"eventTypes": [
"email.sent",
"email.delivered",
"email.bounced",
"email.opened",
"email.clicked",
"email.complained"
],
"description": "Updated production webhook"
}'Response (200 OK):
{
"id": "wh_abc123def456",
"url": "https://yourdomain.com/webhooks/unsent",
"description": "Updated production webhook",
"eventTypes": [
"email.sent",
"email.delivered",
"email.bounced",
"email.opened",
"email.clicked",
"email.complained"
],
"status": "ACTIVE",
"secret": "wh_sec_***",
"apiVersion": null,
"consecutiveFailures": 0,
"lastSuccessAt": "2024-01-15T12:00:00Z",
"lastFailureAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T13:00:00Z",
"teamId": "team_xyz789",
"createdByUserId": "user_abc123"
}[!WARNING] When using
rotateSecret, the new secret will only be shown once in the response. Make sure to store it securely before the response is lost.
Error Responses:
- 404 Not Found - Webhook not found
- 400 Bad Request - Invalid request parameters
Delete Webhook
DELETE /v1/webhooks/{id}
Delete a webhook endpoint
Path Parameters:
id(string, required) - The webhook ID to delete
curl -X DELETE https://api.unsent.dev/v1/webhooks/wh_abc123def456 \
-H "Authorization: Bearer your-api-key"Response (200 OK):
{
"id": "wh_abc123def456",
"url": "https://yourdomain.com/webhooks/unsent",
"description": "Production webhook endpoint",
"eventTypes": [
"email.sent",
"email.delivered"
],
"status": "ACTIVE",
"secret": "wh_sec_***",
"apiVersion": null,
"consecutiveFailures": 0,
"lastSuccessAt": "2024-01-15T12:00:00Z",
"lastFailureAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T13:00:00Z",
"teamId": "team_xyz789",
"createdByUserId": "user_abc123"
}[!CAUTION] Deleting a webhook is permanent and cannot be undone. Any pending deliveries will be cancelled.
Error Responses:
- 404 Not Found - Webhook not found
Test Webhook
POST /v1/webhooks/{id}/test
Trigger a test event to verify your webhook endpoint is working correctly
Path Parameters:
id(string, required) - The webhook ID to test
curl -X POST https://api.unsent.dev/v1/webhooks/wh_abc123def456/test \
-H "Authorization: Bearer your-api-key"Response (200 OK):
{
"id": "whc_test123",
"type": "webhook.test",
"createdAt": "2024-01-15T14:00:00Z",
"updatedAt": "2024-01-15T14:00:01Z",
"teamId": "team_xyz789",
"status": "SUCCESS",
"webhookId": "wh_abc123def456",
"payload": "{\"id\":\"evt_test123\",\"type\":\"webhook.test\",\"createdAt\":\"2024-01-15T14:00:00Z\",\"data\":{\"message\":\"This is a test webhook event\"}}",
"attempt": 1,
"nextAttemptAt": null,
"lastError": null,
"responseStatus": 200,
"responseTimeMs": 145,
"responseText": "{\"received\":true}"
}[!NOTE] The test event sends a
webhook.testevent type that won't be included in your regular event subscriptions. Use this to verify your endpoint can receive and process webhook payloads.
Error Responses:
- 500 Internal Server Error - Webhook delivery failed
{ "code": "INTERNAL_SERVER_ERROR", "message": "Failed to deliver test webhook" }
Event Types
Unsent supports the following webhook event types. You can subscribe to any combination of these events when creating or updating a webhook.
Email Events
| Event Type | Description |
|---|---|
email.queued | Email has been queued for sending |
email.sent | Email has been successfully sent to the mail server |
email.delivery_delayed | Email delivery is delayed (temporary issue) |
email.delivered | Email was successfully delivered to recipient's inbox |
email.bounced | Email bounced (permanent or temporary) |
email.rejected | Email was rejected by the recipient's server |
email.rendering_failure | Email template failed to render |
email.complained | Recipient marked the email as spam |
email.failed | Email sending failed |
email.cancelled | Email was cancelled before delivery |
email.suppressed | Email was suppressed due to suppression list |
email.opened | Recipient opened the email (requires open tracking) |
email.clicked | Recipient clicked a link in the email (requires click tracking) |
Contact Events
| Event Type | Description |
|---|---|
contact.created | A new contact was created |
contact.updated | An existing contact was updated |
contact.deleted | A contact was deleted |
Domain Events
| Event Type | Description |
|---|---|
domain.created | A new domain was added |
domain.verified | A domain was successfully verified |
domain.updated | Domain settings were updated |
domain.deleted | A domain was removed |
Webhook Payload Structure
All webhook events follow a consistent payload structure:
{
"id": "evt_unique_event_id",
"type": "email.delivered",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
// Event-specific payload data
}
}Email Event Payload
{
"id": "evt_abc123",
"type": "email.delivered",
"createdAt": "2024-01-15T10:30:00Z",
"data": {
"id": "email_xyz789",
"status": "DELIVERED",
"from": "noreply@yourdomain.com",
"to": ["recipient@example.com"],
"occurredAt": "2024-01-15T10:30:00Z",
"subject": "Welcome to our service",
"campaignId": "camp_123",
"contactId": "contact_456",
"domainId": 1,
"templateId": "tpl_789",
"metadata": {
"userId": "user_abc",
"environment": "production"
}
}
}Email Bounced Event
{
"id": "evt_bounce123",
"type": "email.bounced",
"createdAt": "2024-01-15T10:35:00Z",
"data": {
"id": "email_xyz789",
"status": "BOUNCED",
"from": "noreply@yourdomain.com",
"to": ["invalid@example.com"],
"occurredAt": "2024-01-15T10:35:00Z",
"subject": "Welcome email",
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "Mailbox does not exist"
}
}
}Email Opened Event
{
"id": "evt_open123",
"type": "email.opened",
"createdAt": "2024-01-15T11:00:00Z",
"data": {
"id": "email_xyz789",
"status": "OPENED",
"from": "noreply@yourdomain.com",
"to": ["recipient@example.com"],
"occurredAt": "2024-01-15T11:00:00Z",
"subject": "Welcome email",
"open": {
"timestamp": "2024-01-15T11:00:00Z",
"userAgent": "Mozilla/5.0...",
"ip": "192.168.1.1",
"platform": "Desktop"
}
}
}Email Clicked Event
{
"id": "evt_click123",
"type": "email.clicked",
"createdAt": "2024-01-15T11:05:00Z",
"data": {
"id": "email_xyz789",
"status": "CLICKED",
"from": "noreply@yourdomain.com",
"to": ["recipient@example.com"],
"occurredAt": "2024-01-15T11:05:00Z",
"subject": "Welcome email",
"click": {
"timestamp": "2024-01-15T11:05:00Z",
"url": "https://yourdomain.com/verify",
"userAgent": "Mozilla/5.0...",
"ip": "192.168.1.1",
"platform": "Desktop"
}
}
}Contact Event Payload
{
"id": "evt_contact123",
"type": "contact.created",
"createdAt": "2024-01-15T10:00:00Z",
"data": {
"id": "contact_abc123",
"email": "newuser@example.com",
"contactBookId": "book_xyz789",
"subscribed": true,
"firstName": "John",
"lastName": "Doe",
"properties": {
"plan": "premium",
"signupDate": "2024-01-15"
},
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:00:00Z"
}
}Domain Event Payload
{
"id": "evt_domain123",
"type": "domain.verified",
"createdAt": "2024-01-15T09:00:00Z",
"data": {
"id": 1,
"name": "yourdomain.com",
"status": "SUCCESS",
"region": "us-east-1",
"clickTracking": true,
"openTracking": true,
"dkimStatus": "Success",
"spfDetails": "v=spf1 include:amazonses.com ~all",
"dmarcAdded": true,
"createdAt": "2024-01-14T10:00:00Z",
"updatedAt": "2024-01-15T09:00:00Z"
}
}Webhook Security
HMAC Signature Verification
Unsent signs all webhook requests using HMAC-SHA256 with your webhook secret. This allows you to verify that webhooks are genuinely from Unsent and haven't been tampered with.
Signature Header:
X-Unsent-Signature: sha256=<signature>Verifying Signatures
Extract the signature from headers
Get the X-Unsent-Signature header value from the incoming webhook request.
const signature = request.headers['x-unsent-signature'];Compute the expected signature
Use your webhook secret to compute an HMAC-SHA256 signature of the raw request body.
const crypto = require('crypto');
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(rawRequestBody)
.digest('hex');
const expectedHeader = `sha256=${expectedSignature}`;Compare signatures securely
Use a constant-time comparison to prevent timing attacks.
const crypto = require('crypto');
function verifySignature(signature, expectedSignature) {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
if (verifySignature(signature, expectedHeader)) {
// Signature is valid, process the webhook
} else {
// Invalid signature, reject the request
return res.status(401).send('Invalid signature');
}Complete Example (Node.js/Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'your-webhook-secret';
app.post('/webhooks/unsent',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-unsent-signature'];
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const expectedHeader = `sha256=${expectedSignature}`;
// Verify signature
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedHeader)
)) {
return res.status(401).send('Invalid signature');
}
// Parse and process the webhook
const event = JSON.parse(req.body.toString());
console.log('Received event:', event.type);
console.log('Event data:', event.data);
// Process based on event type
switch (event.type) {
case 'email.delivered':
// Handle delivered email
break;
case 'email.bounced':
// Handle bounced email
break;
// ... other event types
}
res.status(200).send('Webhook received');
}
);[!IMPORTANT] Always verify webhook signatures before processing the payload to ensure the request is legitimate.
Webhook Status
Webhooks can have the following status values:
| Status | Description |
|---|---|
ACTIVE | Webhook is active and receiving events |
PAUSED | Webhook is paused and not receiving events |
FAILED | Webhook has been automatically paused due to repeated failures |
Automatic Failure Management
- If a webhook endpoint fails to respond with a 2xx status code, Unsent will retry the delivery with exponential backoff
- After consecutive failures, the webhook may be automatically paused to prevent further failed attempts
- You can monitor failures via the
consecutiveFailures,lastSuccessAt, andlastFailureAtfields
Best Practices
-
Use HTTPS endpoints only
Webhooks must use HTTPS URLs for security. HTTP endpoints are not supported. -
Verify signatures
Always validate theX-Unsent-Signatureheader to ensure webhooks are from Unsent. -
Respond quickly
Process webhooks asynchronously. Respond with a 2xx status code within a few seconds to avoid timeouts. -
Handle idempotency
Webhooks may be delivered more than once. Use the eventidto deduplicate events. -
Monitor failures
Regularly check webhook status and failure counts. Set up alerts for repeated failures. -
Use the test endpoint
Test your webhook endpoint usingPOST /v1/webhooks/{id}/testbefore going live. -
Subscribe selectively
Only subscribe to events you need to reduce unnecessary traffic. -
Store secrets securely
Never expose webhook secrets in client-side code or version control. -
Implement retry logic
If your processing fails, implement your own retry mechanism. -
Use appropriate event types
Choose event types that match your use case (e.g., only subscribe toemail.deliveredinstead of all email events if you only care about deliveries).
Common Issues & Troubleshooting
Webhook not receiving events
Cause: Webhook may be paused, endpoint unreachable, or event types not matching
Solution:
- Verify webhook status is
ACTIVE - Ensure your endpoint is publicly accessible via HTTPS
- Check that you're subscribed to the event types you expect
- Use the test endpoint to verify connectivity
Signature verification failing
Cause: Incorrect secret or request body modification
Solution:
- Ensure you're using the correct webhook secret
- Verify you're computing the signature on the raw request body (before JSON parsing)
- Check that no middleware is modifying the request body
High consecutive failures
Cause: Endpoint errors, timeouts, or incorrect response codes
Solution:
- Check your endpoint logs for errors
- Ensure your endpoint responds within a few seconds
- Return a 2xx status code to acknowledge receipt
- Process webhooks asynchronously to avoid timeouts
Events delivered multiple times
Cause: Normal behavior - webhooks may be retried on network issues
Solution:
- Implement idempotency using the event
idfield - Track processed event IDs to avoid duplicate processing
Security Considerations
[!CAUTION]
- Always use HTTPS endpoints for webhook URLs
- Verify webhook signatures on every request
- Never expose your webhook secret in client code
- Implement rate limiting on your webhook endpoint
- Log webhook failures and monitor for suspicious activity
- Rotate webhook secrets periodically for extra security