Webhooks

Menu

Webhooks notify your application in real-time when email events occur, such as bounces or spam complaints.

Creating a Webhook

  1. Go to Webhooks in your dashboard
  2. Click Create Webhook
  3. Enter your endpoint URL (a public host name on port 443, 80 or 8080; use HTTPS)
  4. Select the events you want to receive
  5. Optionally, restrict to a specific domain
  6. Click Create Webhook

Events

EventDescription
bouncedEmail could not be delivered (hard or soft bounce)
complaintRecipient 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:

HeaderDescription
Content-Typeapplication/json
User-AgentToSend-Webhook/2.0
X-ToSend-EventEvent type: bounced or complaint
X-ToSend-TimestampISO-8601 UTC timestamp of dispatch
X-ToSend-Signaturesha256=<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"
}
FieldTypeDescription
typestringEvent type: bounced or complaint
dataobjectEvent-specific payload (see below)
created_atstringISO-8601 UTC timestamp when the webhook was dispatched
mailobjectPresent 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 FieldTypeDescription
emailstringThe 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_typestringPermanent, Transient, or Undetermined. It is suppression when the send was stopped because the address was already on your suppression list.
bounce_sub_typestringE.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_bouncebooleantrue for permanent bounces, false for soft
sender_faultbooleantrue 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.
reasonstringSMTP diagnostic message
timestampstringWhen the bounce was reported
message_idstringThe 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:

EventHeld for
Hard bounce60 days
Soft bounce24 hours, then sending resumes automatically
Complaint180 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 FieldTypeDescription
emailstringThe recipient who complained. Always a bare address, as with bounce events.
feedback_typestringARF feedback type (abuse, fraud, other, etc.)
reasonstringSame as feedback_type
timestampstringWhen the complaint was reported
message_idstringThe 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"
  }
}
FieldTypeDescription
mail.idstringThe message_id (same as data.message_id)
mail.subjectstringEmail subject line
mail.from_namestring | nullSender display name
mail.from_emailstringSender email address
mail.reply_tostring | nullReply-to address
mail.to_detailsarrayto recipients [{email, name}]
mail.other_recipientsobject{ cc: [...], bcc: [...] }
mail.custom_headersobject | nullCustom headers supplied at send time
mail.statusstringLog status: sent, bounced, complained, suppressed, failed
mail.error_messagestring | nullError message if delivery failed
mail.created_atstringWhen 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

  1. Find the webhook in your list
  2. Click Delete
  3. Confirm deletion

Responding to Webhooks

Your endpoint must acknowledge each event with:

  1. A 2xx status code, such as 200
  2. A JSON body with Content-Type: application/json, such as {"received": true}
  3. 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-Signature against the raw request body when a secret is configured