Webhooks
Receive real-time notifications when message events occur.
Overview
Webhooks let you subscribe to events like message delivery, opens, clicks, bounces, and more. When an event occurs, YourSend sends an HTTP POST request to your configured endpoint with a signed JSON payload.
Setup
You can create webhooks from the dashboard or via the API.
From the Dashboard
- Go to Dashboard → Webhooks
- Click Add Endpoint
- Enter your HTTPS URL (e.g.,
https://yourapp.com/api/webhooks/yoursend) - Select the events you want to subscribe to
- Copy and store the signing secret — it is only shown once
Via the API
curl -X POST https://api.yoursend.dev/v1/webhooks \
-H "Authorization: Bearer ys_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/api/webhooks/yoursend",
"events": ["message.sent", "message.delivered", "message.failed"]
}'Event Types
Not all events fire for every channel. Open and click tracking is available for email only.
| Event | Description | Channels |
|---|---|---|
| message.sent | Message accepted and sent to provider | Email, SMS, Voice |
| message.delivered | Message confirmed delivered to recipient | Email, SMS, Voice |
| message.opened | Recipient opened the email | |
| message.clicked | Recipient clicked a link in the email | |
| message.bounced | Email bounced (hard or soft) | |
| message.failed | Message delivery failed | Email, SMS, Voice |
| otp.verified | OTP code was successfully verified | Email, SMS, Voice |
| otp.expired | OTP expired before verification | Email, SMS, Voice |
| contact.suppressed | Contact auto-suppressed (bounce/complaint) | |
| blast.completed | Bulk email blast finished sending | |
| email.received | Inbound email received on your domain |
Payload Format
Every webhook delivers a JSON body with this structure:
{
"event": "message.delivered",
"data": {
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"to_address": "user@example.com",
"channel": "email",
"status": "delivered"
},
"timestamp": "2026-03-18T12:00:02.000Z",
"webhook_id": "d290f1ee-6c54-4b01-90e6-d701748f0851"
}| Field | Description |
|---|---|
event | The event type (e.g. message.delivered) |
data | Event-specific payload. Always includes message_id and channel for message events. |
timestamp | ISO 8601 timestamp of when the event occurred |
webhook_id | The ID of the webhook endpoint that received this delivery |
Request Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
X-YourSend-Signature | HMAC-SHA256 hex digest for verifying payload authenticity |
X-YourSend-Timestamp | ISO 8601 timestamp used in signature computation |
X-YourSend-Event | The event type (same as event in body) |
Content-Type | application/json |
Signature Verification
Always verify webhook signatures to ensure the request came from YourSend. The signature is computed as HMAC-SHA256(secret, timestamp + "." + body) where timestamp is from the X-YourSend-Timestamp header and body is the raw request body.
import crypto from 'crypto';
function verifyWebhook(req: {
body: string;
headers: Record<string, string>;
}, secret: string): boolean {
const signature = req.headers['x-yoursend-signature'];
const timestamp = req.headers['x-yoursend-timestamp'];
if (!signature || !timestamp) return false;
// Reject requests older than 5 minutes to prevent replay attacks
const age = Date.now() - new Date(timestamp).getTime();
if (age > 5 * 60 * 1000) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + req.body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}import hmac, hashlib
from datetime import datetime, timezone, timedelta
def verify_webhook(body: bytes, headers: dict, secret: str) -> bool:
signature = headers.get("X-YourSend-Signature", "")
timestamp = headers.get("X-YourSend-Timestamp", "")
if not signature or not timestamp:
return False
# Reject requests older than 5 minutes
event_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if datetime.now(timezone.utc) - event_time > timedelta(minutes=5):
return False
expected = hmac.new(
secret.encode(),
(timestamp + "." + body.decode()).encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)Important: Always use a timing-safe comparison (e.g. timingSafeEqual or hmac.compare_digest) to prevent timing attacks. Use the raw request body string, not a re-serialized object.
Retries
If your endpoint returns a non-2xx status code or times out (10 second limit), YourSend makes the initial attempt plus 2 retries on a fixed schedule:
| Attempt | Delay |
|---|---|
| 1st retry | ~1 minute |
| 2nd retry | ~5 minutes |
After the initial attempt and 2 retries fail, the delivery is dropped. Your endpoint should return a 200status code as quickly as possible — process the payload asynchronously if needed.