Documentation menu

Guides

Webhooks

A webhook posts a signed JSON event to your endpoint whenever something happens to an email or a domain: sent, delivered, delayed, bounced, opened, clicked and more. Failed deliveries are retried for about a day.

Create a webhook

In the console open Apps → Core services → tsmartSend → Webhooks → Add a webhook, enter the endpoint URL and choose the events. Or use a full-access key:

cURL
curl -X POST https://send.tsmart.ai/webhooks \
  -H "Authorization: Bearer $TSMART_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://hooks.acme.com/tsmart",
    "events": ["email.delivered", "email.bounced", "email.delivery_delayed", "email.failed"]
  }'
Response
{
  "object": "webhook",
  "id": "5d2c8f3e-1a7b-4c9d-8e6f-0a1b2c3d4e5f",
  "signing_secret": "whsec_mR2u3H8XqVb1yZ0kL7cT9wN4pE6sA5dF1gJ3hK8lM2o="
}
Node.js
const { data, error } = await tsmart.webhooks.create({
  endpoint: "https://hooks.acme.com/tsmart",
  events: ["email.delivered", "email.bounced", "email.delivery_delayed", "email.failed"],
});
// Store data.signing_secret with your endpoint's configuration.
Python
hook = client.webhooks.create({
    "endpoint": "https://hooks.acme.com/tsmart",
    "events": ["email.delivered", "email.bounced", "email.delivery_delayed", "email.failed"],
})
print(hook["signing_secret"])

Store the signing_secret with your endpoint; you need it to verify deliveries. You can read it again with GET /webhooks/{id} or in the console (Show the secret). A company can have up to 10 webhooks; PATCH /webhooks/{id} changes endpoint, events and status (enabled or disabled), DELETE removes one.

Endpoint requirements

  • A public https URL on port 443 or 8443, up to 2,000 characters, without a user name or password in it.
  • The host name must resolve to public addresses only. Private networks, loopback and local names such as .local or .internal are refused.
  • Answer with a 2xx status within 10 seconds. Redirects are not followed and count as failures.
  • Return quickly: store or queue the event and do the work afterwards.

Event types

TypeSent when
email.sentOur mail server accepted the email for delivery.
email.deliveredThe recipient's mail server accepted the email (one event per recipient).
email.delivery_delayedDelivery to a recipient hit a temporary problem and is being retried.
email.bouncedDelivery to a recipient failed permanently, or was given up after retrying.
email.failedThe email could not be handed to the mail server, or a scheduled email could not go out.
email.scheduledAn email was accepted with scheduled_at.
email.suppressedA recipient was skipped because it is on the suppression list.
email.openedThe open-tracking image was loaded (domain with open tracking).
email.clickedA tracked link was followed (domain with click tracking).
email.complainedA recipient marked the email as spam and their mailbox provider sent us a complaint report. Which providers send these reports is explained in Spam complaints.
domain.createdA domain was added through the API.
domain.updatedA domain's status changed on verification through the API, or its open or click tracking was set (console or API).
domain.deletedA domain was removed through the API.

A webhook subscribes to at least one of these types. Canceling a scheduled email is recorded in the email's event history but is not sent to webhooks. Domains added, verified or removed in the console, and status changes found by the platform's periodic re-check, send no domain events.

The request

Every delivery is an HTTPS POST with a JSON body and these headers:

HeaderValue
content-typeapplication/json
user-agenttsmartSend-Webhooks/1.0
webhook-idThe event id, for example msg_2b7xQd9LmK3vR8sT1wYz5aBc4eF. The same on every retry and replay.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signaturev1, followed by the base64 signature (see Verify signatures).

The same values are also sent under an alternative header prefix for compatibility with existing verification libraries.

The body has the event type, the time of the event (created_at, ISO 8601) and data. For email events data always contains:

Name Type Description
email_id string The id returned when the email was sent.
created_at string When the email was accepted (ISO 8601).
from string The sender as sent.
to string[] For delivered, delivery_delayed, bounced, suppressed and complained: the one recipient concerned. Otherwise the email's to addresses. A complained event has an empty list when the mailbox provider hid the recipient and the email had several.
subject string The subject.
tags object The email's tags as name: value pairs.
message_id string The Message-ID header, once the email has been handed to the mail server.

Payload examples

email.sent
{
  "type": "email.sent",
  "created_at": "2026-10-12T09:41:05.402Z",
  "data": {
    "created_at": "2026-10-12T09:41:05.120Z",
    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    "from": "Acme <orders@acme.com>",
    "to": ["jane@example.com"],
    "subject": "Order 1042 confirmed",
    "tags": { "category": "order_confirmation" },
    "message_id": "<4ef9a417-02e9-4d39-ad75-9611e0fcc33c@acme.com>"
  }
}

email.delivered, email.complained and email.scheduled have the same shape (a scheduled email has no message_id yet; see Spam complaints for an email.complained example). The other types add one object:

email.bounced
{
  "type": "email.bounced",
  "created_at": "2026-10-12T09:41:08.999Z",
  "data": {
    "created_at": "2026-10-12T09:41:05.120Z",
    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    "from": "Acme <orders@acme.com>",
    "to": ["no-such-user@example.com"],
    "subject": "Order 1042 confirmed",
    "tags": { "category": "order_confirmation" },
    "message_id": "<4ef9a417-02e9-4d39-ad75-9611e0fcc33c@acme.com>",
    "bounce": {
      "type": "Permanent",
      "subType": "General",
      "message": "550 5.1.1 <no-such-user@example.com>: Recipient address rejected: User unknown"
    }
  }
}
email.delivery_delayed (data.bounce only)
"bounce": {
  "type": "Temporary",
  "subType": "General",
  "message": "451 4.7.1 Please try again later"
}
email.opened (data.open only)
"open": {
  "ipAddress": "203.0.113.24",
  "timestamp": "2026-10-12T10:02:44.318Z",
  "userAgent": "ExampleMailApp/2.1"
}
email.clicked (data.click only)
"click": {
  "ipAddress": "203.0.113.24",
  "link": "https://acme.com/orders/1042",
  "timestamp": "2026-10-12T10:03:10.077Z",
  "userAgent": "ExampleMailApp/2.1"
}
email.failed (data.failed only)
"failed": {
  "reason": "The mail server did not accept the email"
}
email.suppressed (data.suppressed only)
"suppressed": {
  "message": "The recipient is on the suppression list",
  "type": "bounce"
}
domain.updated
{
  "type": "domain.updated",
  "created_at": "2026-10-12T09:35:40.512Z",
  "data": {
    "id": "b7c1e0a2-5d0f-4a51-9a64-0f3c2a7d9e11",
    "name": "acme.com",
    "status": "verified",
    "created_at": "2026-10-12T09:30:11.412Z"
  }
}

domain.created and domain.deleted have the same shape. The bounce message is the receiving server's reply and can be empty. Open and click events do not say which recipient opened or clicked.

Verify signatures

Anyone can post to a public URL, so check every delivery before you trust it. The signature is an HMAC-SHA256 over the event id, the timestamp and the raw body:

  1. Take the signing secret, remove the whsec_ prefix and base64-decode the rest. That is the key.
  2. Build the signed content <webhook-id>.<webhook-timestamp>.<raw body> (the three values joined with dots).
  3. Compute HMAC-SHA256 of it with the key and base64-encode the result.
  4. webhook-signature is a space-separated list of v1,<signature> entries. Accept the request if any v1 entry equals your result, comparing in constant time.
  5. Reject timestamps more than 5 minutes away from your clock, so a captured request cannot be replayed later.

With the SDKs

verifyWebhook (Node.js) and verify_webhook (Python) do all of the above and return the parsed event. They throw WebhookVerificationError when a header is missing, the timestamp is outside the tolerance (300 seconds by default) or no signature matches.

Node.js
import { createServer } from "node:http";
import { verifyWebhook, WebhookVerificationError } from "@tsmart/sdk";

const secret = process.env.TSMART_WEBHOOK_SECRET; // whsec_…

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const payload = Buffer.concat(chunks); // the raw body, exactly as received
    let event;
    try {
      event = verifyWebhook({ payload, headers: req.headers, secret });
    } catch (err) {
      res.writeHead(err instanceof WebhookVerificationError ? 400 : 500).end();
      return;
    }
    // Skip events you have already handled: req.headers["webhook-id"] is unique per event.
    console.log(event.type, event.data.email_id ?? event.data.name);
    res.writeHead(200).end();
  });
}).listen(3000);
Python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from tsmart import WebhookVerificationError, verify_webhook

SECRET = os.environ["TSMART_WEBHOOK_SECRET"]  # whsec_...


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        payload = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # raw body
        try:
            event = verify_webhook(payload=payload, headers=dict(self.headers), secret=SECRET)
        except WebhookVerificationError:
            self.send_response(400)
            self.end_headers()
            return
        # Skip events you have already handled: the webhook-id header is unique per event.
        print(event["type"], event["data"].get("email_id"))
        self.send_response(200)
        self.end_headers()


HTTPServer(("", 8000), Handler).serve_forever()

Without the SDKs

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

/** payload: the raw body (Buffer or string); headers: lower-case header names. Returns the event or throws. */
export function verifyTsmartWebhook(payload, headers, secret, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) throw new Error("Missing webhook headers");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    throw new Error("Timestamp outside the tolerance");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(payload).digest();
  const valid = signature.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("Invalid signature");
  return JSON.parse(payload.toString("utf8"));
}
Python
import base64
import hashlib
import hmac
import json
import time


def verify_tsmart_webhook(payload: bytes, headers, secret: str, tolerance: int = 300):
    """payload: the raw body; headers: a mapping with lower-case names. Returns the event or raises ValueError."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signature:
        raise ValueError("Missing webhook headers")
    if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > tolerance:
        raise ValueError("Timestamp outside the tolerance")

    if secret.startswith("whsec_"):
        secret = secret[len("whsec_"):]
    key = base64.b64decode(secret)
    signed = msg_id.encode() + b"." + timestamp.encode() + b"." + payload
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in signature.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode(), expected.encode()):
            return json.loads(payload)
    raise ValueError("Invalid signature")

Retries

A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Anything else (another status, a redirect, a timeout, a connection error) is a failed attempt, and the event is tried again:

AttemptWhen
1Within seconds of the event
25 seconds after the first failure
35 minutes after the previous attempt
430 minutes later
52 hours later
65 hours later
710 hours later
810 hours later; if this fails too, the event is marked failed
  • Delivery is at least once: a retry or a replay can bring an event you have already processed. Use webhook-id to skip duplicates.
  • Events can arrive out of order. Use created_at in the body to order them.

Automatic disabling

After 100 failed attempts in a row, across all of its events, a webhook is disabled. Any successful delivery resets the count. While a webhook is disabled, no new events are queued for it; events that were already waiting are kept and delivered as soon as you enable it again, in the console or with PATCH /webhooks/{id} and {"status": "enabled"}, which also resets the count. Waiting events of a disabled webhook are deleted after 30 days.

Test events

Send a test event on the webhook's page, or call POST /webhooks/{id}/test, to queue an example email.sent event. It is delivered and retried like a real one:

Test payload
{
  "type": "email.sent",
  "created_at": "2026-10-12T09:50:00.000Z",
  "data": {
    "created_at": "2026-10-12T09:50:00.000Z",
    "email_id": "00000000-0000-4000-8000-000000000000",
    "from": "Example <hello@example.com>",
    "to": ["someone@example.com"],
    "subject": "Test event",
    "tags": { "test": "true" }
  }
}

The email_id of a test event is always 00000000-0000-4000-8000-000000000000, so your handler can recognize it. A disabled webhook cannot be tested (422 validation_error). While 5 test events of a webhook are still waiting for their retries, further tests are refused with 429 rate_limit_exceeded; replay one of them to try it at once.

Inspect, replay and debug

Every event sent to a webhook is kept for 30 days with its delivery attempts. The console shows them on the webhook's page; a full-access key can read them too:

EndpointReturns
GET /webhooks/{id}/events Events of the webhook, newest first: id, type, created_at, status.
GET /webhooks/{id}/events/{event_id} One event with its payload, number of attempts and next_attempt_at.
GET /webhooks/{id}/events/{event_id}/attempts The last 50 attempts, newest first: http_status_code, response (the first 500 characters of your endpoint's answer), error, duration_ms, sent_at.
POST /webhooks/{id}/events/{event_id}/replay Delivers the event again right away, with the same id.

An event's status is pending (not tried yet), attempting (failed so far, retries left), success or failed (all retries used). Replaying a disabled webhook's event is refused with 422 validation_error. Before your endpoint's response is stored, its path and query string are removed from it, together with each path segment, query pair and query value of 6 characters or more. If the URL carries a token, make it a long random value, so it is removed even when your endpoint echoes it back.

cURL
curl "https://send.tsmart.ai/webhooks/5d2c8f3e-1a7b-4c9d-8e6f-0a1b2c3d4e5f/events?limit=10" \
  -H "Authorization: Bearer $TSMART_API_KEY"

curl -X POST https://send.tsmart.ai/webhooks/5d2c8f3e-1a7b-4c9d-8e6f-0a1b2c3d4e5f/events/msg_2b7xQd9LmK3vR8sT1wYz5aBc4eF/replay \
  -H "Authorization: Bearer $TSMART_API_KEY"

Rotate the signing secret

Create a new secret in the console (Create a new secret) or with POST /webhooks/{id}/signing-secret/rotate. The response contains the new signing_secret, and the old one stops working at once: every delivery from then on is signed with the new secret.

cURL
curl -X POST https://send.tsmart.ai/webhooks/5d2c8f3e-1a7b-4c9d-8e6f-0a1b2c3d4e5f/signing-secret/rotate \
  -H "Authorization: Bearer $TSMART_API_KEY"

To rotate without losing events, let your endpoint accept either secret for a short time: deploy a version that tries the new secret and falls back to the old one, rotate, then remove the old secret. A delivery that your endpoint rejects in between is simply retried.