Webhooks notify your application in real-time when email events occur, such as bounces or spam complaints.
Creating a Webhook
- Go to Webhooks in your dashboard
- Click Create Webhook
- Enter your endpoint URL (a public host name on port 443, 80 or 8080; use HTTPS)
- Select the events you want to receive
- Optionally, restrict to a specific domain
- Click Create Webhook
Events
| Event | Description |
|---|---|
bounced | Email could not be delivered (hard or soft bounce) |
complaint | Recipient marked the email as spam |
These two are the complete list. There are no delivered, opened or clicked events.
Request Headers
Every webhook POST includes:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | ToSend-Webhook/2.0 |
X-ToSend-Event | Event type: bounced or complaint |
X-ToSend-Timestamp | ISO-8601 UTC timestamp of dispatch |
X-ToSend-Signature | sha256=<hex> HMAC signature (only when the webhook has a secret configured) |
Webhook Payload
When an event occurs, toSend sends a POST request with this top-level shape:
{
"type": "bounced",
"data": { /* event-specific fields */ },
"created_at": "2026-04-18T10:30:00.000Z"
}
| Field | Type | Description |
|---|---|---|
type | string | Event type: bounced or complaint |
data | object | Event-specific payload (see below) |
created_at | string | ISO-8601 UTC timestamp when the webhook was dispatched |
mail | object | Present only when include_message is enabled (see below) |
Bounce Event
{
"type": "bounced",
"data": {
"email": "recipient@example.com",
"bounce_type": "Permanent",
"bounce_sub_type": "General",
"is_hard_bounce": true,
"sender_fault": false,
"reason": "smtp; 550 5.1.1 The email account does not exist",
"timestamp": "2026-04-18T10:29:58.000Z",
"message_id": "msg_abc123..."
},
"created_at": "2026-04-18T10:30:00.000Z"
}
data Field | Type | Description |
|---|---|---|
email | string | The recipient address that bounced. Always a bare address (user@example.com), never a Display Name <user@example.com> form, so it can be compared or looked up directly. |
bounce_type | string | Permanent, Transient, or Undetermined. It is suppression when the send was stopped because the address was already on your suppression list. |
bounce_sub_type | string | E.g. General, NoEmail, Suppressed, OnAccountSuppressionList, SuppressionList (already on your suppression list), NoMxRecords (the recipient domain has no mail server; sent as a Permanent bounce without attempting delivery). SenderBlocklisted means the receiving server rejected the sending IP on a public blocklist; the address is fine and is not suppressed. |
is_hard_bounce | boolean | true for permanent bounces, false for soft |
sender_fault | boolean | true when the bounce was caused by the message or the sending side (ContentRejected, MessageTooLarge, AttachmentRejected, SenderBlocklisted) rather than the recipient. Do not penalise the recipient when this is true. |
reason | string | SMTP diagnostic message |
timestamp | string | When the bounce was reported |
message_id | string | The message_id returned when you sent the email |
What toSend already does for you
You do not need to suppress these addresses yourself. toSend stops sending to them automatically, and releases them on its own:
| Event | Held for |
|---|---|
| Hard bounce | 60 days |
| Soft bounce | 24 hours, then sending resumes automatically |
| Complaint | 180 days |
So there is nothing to un-suppress after a soft bounce. Use the webhook to update your own contact records; the sending side is already handled. You can view and remove entries at any time from the Suppressions page.
Complaint Event
{
"type": "complaint",
"data": {
"email": "recipient@example.com",
"feedback_type": "abuse",
"reason": "abuse",
"timestamp": "2026-04-18T10:29:58.000Z",
"message_id": "msg_abc123..."
},
"created_at": "2026-04-18T10:30:00.000Z"
}
data Field | Type | Description |
|---|---|---|
email | string | The recipient who complained. Always a bare address, as with bounce events. |
feedback_type | string | ARF feedback type (abuse, fraud, other, etc.) |
reason | string | Same as feedback_type |
timestamp | string | When the complaint was reported |
message_id | string | The message_id returned when you sent the email |
Include Message Content
Set include_message to true when you create or update a webhook through the Admin API to receive the full email metadata alongside the event. The dashboard does not show this option. Useful for debugging; increases payload size.
When enabled, a mail object is added at the top level:
{
"type": "bounced",
"data": { "...": "..." },
"created_at": "2026-04-18T10:30:00.000Z",
"mail": {
"id": "msg_abc123...",
"subject": "Welcome to our platform",
"from_name": "Your App",
"from_email": "noreply@yourdomain.com",
"reply_to": "support@yourdomain.com",
"to_details": [{ "email": "recipient@example.com", "name": "John" }],
"other_recipients": { "cc": [], "bcc": [] },
"custom_headers": { "X-Campaign": "welcome-v2" },
"status": "bounced",
"error_message": "smtp; 550 5.1.1 The email account does not exist",
"created_at": "2026-04-18T10:28:14.000Z"
}
}
| Field | Type | Description |
|---|---|---|
mail.id | string | The message_id (same as data.message_id) |
mail.subject | string | Email subject line |
mail.from_name | string | null | Sender display name |
mail.from_email | string | Sender email address |
mail.reply_to | string | null | Reply-to address |
mail.to_details | array | to recipients [{email, name}] |
mail.other_recipients | object | { cc: [...], bcc: [...] } |
mail.custom_headers | object | null | Custom headers supplied at send time |
mail.status | string | Log status: sent, bounced, complained, suppressed, failed |
mail.error_message | string | null | Error message if delivery failed |
mail.created_at | string | When the email was accepted (ISO-8601 UTC) |
Verifying Signatures
When a webhook has a secret configured, every request is signed with HMAC-SHA256 over the raw request body using your secret. The hex digest is sent in the X-ToSend-Signature header as sha256=<hex>.
Compute the same HMAC on your side and compare in constant time:
// Node.js / Express: receive the raw body (not the parsed JSON)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post(
'/webhooks/tosend',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-ToSend-Signature') || '';
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.TOSEND_WEBHOOK_SECRET)
.update(req.body) // Buffer of the raw bytes
.digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).json({ received: true });
processWebhook(event);
},
);
# Python / Flask
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/tosend")
def tosend_webhook():
secret = os.environ["TOSEND_WEBHOOK_SECRET"].encode()
expected = "sha256=" + hmac.new(secret, request.data, hashlib.sha256).hexdigest()
sig = request.headers.get("X-ToSend-Signature", "")
if not hmac.compare_digest(sig, expected):
abort(401)
# request.get_json() is safe to call after signature verification
return {"received": True}, 200
Domain Scoping
- All Domains: Receive events for all domains in your account
- Specific Domain: Only receive events for the selected domain
Managing Webhooks
Update a Webhook
Click Edit on a webhook to change:
- Endpoint URL
- Event subscriptions
- Domain scope
- Status (Active/Disabled)
Delete a Webhook
- Find the webhook in your list
- Click Delete
- Confirm deletion
Responding to Webhooks
Your endpoint must acknowledge each event with:
- A
2xxstatus code, such as200 - A JSON body with
Content-Type: application/json, such as{"received": true} - A response within 10 seconds, so process the event asynchronously if that takes longer
// Example Express.js handler
app.post('/webhooks/tosend', (req, res) => {
const event = req.body;
// Acknowledge immediately
res.status(200).json({ received: true });
// Process asynchronously
processWebhook(event);
});
Delivery and Failures
Each event is delivered once. If your endpoint returns a non-2xx status, answers with an HTML page, or does not respond within 10 seconds, the delivery is recorded as failed in the webhook log in your dashboard and is not retried. The event is not queued for later.
This means a webhook endpoint that is down, even briefly, loses the events it missed. If that matters for your use case, reconcile against the Logs page, which holds every bounce and complaint for 14 days regardless of webhook outcome, and your Suppressions list, which is not pruned.
The webhook log in your dashboard shows the status code, the Content-Type, and the first 500 bytes of each response your endpoint sent, so you can see what actually answered.
Best Practices
- Use HTTPS:
http://endpoints are accepted, but the payload then travels unencrypted - Respond quickly: Return a 2xx JSON response within 10 seconds, then process asynchronously
- Handle duplicates: The same event may be delivered more than once, so dedupe on
data.message_id+type - Verify the signature: Always check
X-ToSend-Signatureagainst the raw request body when a secret is configured