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 -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"]
}' {
"object": "webhook",
"id": "5d2c8f3e-1a7b-4c9d-8e6f-0a1b2c3d4e5f",
"signing_secret": "whsec_mR2u3H8XqVb1yZ0kL7cT9wN4pE6sA5dF1gJ3hK8lM2o="
} 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. 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
httpsURL 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
.localor.internalare refused. - Answer with a
2xxstatus 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
| Type | Sent when |
|---|---|
email.sent | Our mail server accepted the email for delivery. |
email.delivered | The recipient's mail server accepted the email (one event per recipient). |
email.delivery_delayed | Delivery to a recipient hit a temporary problem and is being retried. |
email.bounced | Delivery to a recipient failed permanently, or was given up after retrying. |
email.failed | The email could not be handed to the mail server, or a scheduled email could not go out. |
email.scheduled | An email was accepted with scheduled_at. |
email.suppressed | A recipient was skipped because it is on the suppression list. |
email.opened | The open-tracking image was loaded (domain with open tracking). |
email.clicked | A tracked link was followed (domain with click tracking). |
email.complained | A 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.created | A domain was added through the API. |
domain.updated | A domain's status changed on verification through the API, or its open or click tracking was set (console or API). |
domain.deleted | A 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:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | tsmartSend-Webhooks/1.0 |
webhook-id | The event id, for example msg_2b7xQd9LmK3vR8sT1wYz5aBc4eF. The same on every retry and replay. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | v1, 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
{
"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:
{
"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"
}
}
} "bounce": {
"type": "Temporary",
"subType": "General",
"message": "451 4.7.1 Please try again later"
} "open": {
"ipAddress": "203.0.113.24",
"timestamp": "2026-10-12T10:02:44.318Z",
"userAgent": "ExampleMailApp/2.1"
} "click": {
"ipAddress": "203.0.113.24",
"link": "https://acme.com/orders/1042",
"timestamp": "2026-10-12T10:03:10.077Z",
"userAgent": "ExampleMailApp/2.1"
} "failed": {
"reason": "The mail server did not accept the email"
} "suppressed": {
"message": "The recipient is on the suppression list",
"type": "bounce"
} {
"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:
- Take the signing secret, remove the
whsec_prefix and base64-decode the rest. That is the key. -
Build the signed content
<webhook-id>.<webhook-timestamp>.<raw body>(the three values joined with dots). - Compute HMAC-SHA256 of it with the key and base64-encode the result.
-
webhook-signatureis a space-separated list ofv1,<signature>entries. Accept the request if anyv1entry equals your result, comparing in constant time. - 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.
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); 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
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"));
} 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:
| Attempt | When |
|---|---|
| 1 | Within seconds of the event |
| 2 | 5 seconds after the first failure |
| 3 | 5 minutes after the previous attempt |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 5 hours later |
| 7 | 10 hours later |
| 8 | 10 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-idto skip duplicates. - Events can arrive out of order. Use
created_atin 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:
{
"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:
| Endpoint | Returns |
|---|---|
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 "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 -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.