API reference
Webhooks
Webhooks post signed JSON events to your HTTPS endpoint when something happens to your emails or domains. These endpoints manage webhooks and let you inspect and replay every delivery.
All endpoints need a full-access key whose creator holds the API controls privilege in the console. They can also return the common errors listed in the overview. For receiving and verifying events step by step see Webhooks.
| Endpoint | Node.js | Python | CLI |
|---|---|---|---|
| GET /webhooks | webhooks.list | webhooks.list | tsmart webhooks list |
| POST /webhooks | webhooks.create | webhooks.create | tsmart webhooks create |
| GET /webhooks/{id} | webhooks.get | webhooks.get | tsmart webhooks get |
| PATCH /webhooks/{id} | webhooks.update | webhooks.update | tsmart webhooks update |
| DELETE /webhooks/{id} | webhooks.remove | webhooks.remove | tsmart webhooks delete |
| POST /webhooks/{id}/signing-secret/rotate | webhooks.rotateSecret | webhooks.rotate_secret | tsmart webhooks rotate-secret |
| POST /webhooks/{id}/test | webhooks.test | webhooks.test | tsmart webhooks test |
| GET /webhooks/{id}/events | webhooks.events.list | webhooks.events.list | tsmart webhooks events |
| GET /webhooks/{id}/events/{event_id} | webhooks.events.get | webhooks.events.get | tsmart webhooks event |
| GET …/events/{event_id}/attempts | webhooks.events.attempts | webhooks.events.attempts | tsmart webhooks attempts |
| POST …/events/{event_id}/replay | webhooks.events.replay | webhooks.events.replay | tsmart webhooks replay |
The webhook object
| Name | Type | Description |
|---|---|---|
object | string | "webhook" (single webhook responses only). |
id | string | Webhook id (UUID). |
endpoint | string | The HTTPS URL events are posted to. |
events | string[] | The event types this webhook receives. |
status | string | enabled or disabled. A disabled webhook receives nothing. Events already queued for it wait, but events that happen while it is disabled are not recorded for it and are never delivered. |
created_at | string | When the webhook was created. |
signing_secret | string | The secret that signs every delivery, whsec_ followed by base64. |
A company can have at most 10 webhooks. The endpoint must be:
- an
https://URL of at most 2,000 characters, on the default port, 443 or 8443, without a user name or password; - a public address: private and internal IP addresses and host names without a dot (or ending in .local, .internal, .lan and similar) are refused, and deliveries to a name that resolves to a private address fail.
Event types
| Type | Sent when |
|---|---|
email.sent | An email was handed to the mail server. |
email.delivered | A recipient's server accepted it (one event per recipient). |
email.delivery_delayed | Delivery to a recipient failed temporarily and is being retried. |
email.bounced | Delivery to a recipient failed. |
email.complained | A recipient marked the email as spam and their mailbox provider sent us a complaint report (see Spam complaints). |
email.failed | The email could not be sent. |
email.opened | The email was opened (open tracking on). |
email.clicked | A tracked link was clicked (click tracking on). |
email.scheduled | An email was accepted with scheduled_at. |
email.suppressed | A recipient was skipped because it is on the suppression list. |
domain.created | A domain was added through the API. |
domain.updated | A domain's status changed on POST /domains/{id}/verify, or its open or click tracking was set (console or API). |
domain.deleted | A domain was removed through the API. |
Domains added, verified or removed in the console, and status changes found by the platform's periodic re-check, send no domain events.
Deliveries
Each event is a POST to your endpoint with a JSON body and these headers:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | tsmartSend-Webhooks/1.0 |
webhook-id | The event id (msg_…), the same on every retry and replay: use it to ignore duplicates. |
webhook-timestamp | Unix time in seconds when this attempt was signed. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of the signing secret after whsec_. |
The same values are also sent under an alternative header prefix for compatibility with existing verification libraries.
A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds; redirects are not followed. A failed delivery is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours (8 attempts in all). After 100 failed attempts in a row the webhook is disabled. The events already queued for it are held, but events that happen while it is disabled are not recorded for it and are never delivered. Enabling it again with PATCH /webhooks/{id} resets the count and delivers the held events at once. Events are kept for 30 days after they were created, both finished events and events still held by a disabled webhook.
import { createServer } from "node:http";
import { verifyWebhook, WebhookVerificationError } from "@tsmart/sdk";
createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks); // verify the exact bytes received, before any JSON parsing
try {
const event = verifyWebhook({ payload: rawBody, headers: req.headers, secret: process.env.TSMART_WEBHOOK_SECRET });
console.log(event.type, event.data);
res.writeHead(200).end();
} catch (err) {
if (err instanceof WebhookVerificationError) return res.writeHead(400).end();
console.error(err);
res.writeHead(500).end();
}
}).listen(3000);
The Python SDK has the same check as verify_webhook. Both reject timestamps more than 5 minutes from now.
Payloads
Email events:
{
"type": "email.bounced",
"created_at": "2026-10-12T09:15:04.999Z",
"data": {
"created_at": "2026-10-12T09:15:02.431Z",
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fa6f60",
"from": "Acme <hello@yourdomain.com>",
"to": ["jane@example.com"],
"subject": "Welcome to Acme",
"tags": { "category": "welcome" },
"message_id": "<4ef9a417-02e9-4d39-ad75-9611e0fa6f60@yourdomain.com>",
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "550 5.1.1 User unknown"
}
}
} | Name | Type | Description |
|---|---|---|
type | string | The event type. |
created_at | string | When the event happened (ISO 8601). |
data.created_at | string | When the email was accepted. |
data.email_id | string | The email id. |
data.from, data.subject | string | As sent. |
data.to | string[] | The recipient the event is about (delivered, delivery_delayed, bounced, suppressed, complained); otherwise the email's to addresses. For complained it is empty when the mailbox provider did not say which of several recipients complained. |
data.tags | object | The email's tags as name: value. |
data.message_id | string | The Message-ID header, once the email was handed to the mail server. |
data.bounce | object | email.bounced (type Permanent) and email.delivery_delayed (type Temporary): type, subType ("General") and the receiving server's message. |
data.open | object | email.opened: ipAddress, timestamp, userAgent. |
data.click | object | email.clicked: ipAddress, link, timestamp, userAgent. |
data.failed | object | email.failed: reason. |
data.suppressed | object | email.suppressed: message and type, the reason the address is on the suppression list ("bounce", "complaint" or "manual"). |
Domain events:
{
"type": "domain.updated",
"created_at": "2026-10-12T09:41:20.118Z",
"data": {
"id": "a3b1c5d2-7e8f-4a6b-9c0d-1e2f3a4b5c6d",
"name": "yourdomain.com",
"status": "verified",
"created_at": "2026-10-12T09:30:11.402Z"
}
} GET /webhooks
Lists the company's webhooks, newest first, with their signing secrets.
Permission: full access; the key's creator needs the API controls privilege.
Query
| Name | Type | Description |
|---|---|---|
limit | integer | 1 to 100, default 20. |
after | string | A webhook id: return older webhooks. |
before | string | A webhook id: return newer webhooks. Use either after or before. |
Request
curl https://send.tsmart.ai/webhooks \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "list",
"has_more": false,
"data": [
{
"id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e",
"endpoint": "https://hooks.yourdomain.com/tsmart",
"events": ["email.delivered", "email.bounced"],
"status": "enabled",
"created_at": "2026-10-11 16:20:44.071392+00",
"signing_secret": "whsec_…"
}
]
} Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 422 | validation_error | limit out of range, both after and before, or an invalid cursor. |
POST /webhooks
Creates a webhook and returns its signing secret.
Permission: full access; the key's creator needs the API controls privilege.
Body
| Name | Type | Description |
|---|---|---|
endpoint required | string | Public HTTPS URL that receives the events (see the endpoint rules above). |
events required | string[] | 1 to 20 event types from the list above. Duplicates are ignored. |
Request
curl -X POST https://send.tsmart.ai/webhooks \
-H "Authorization: Bearer $TSMART_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://hooks.yourdomain.com/tsmart",
"events": ["email.delivered", "email.bounced"]
}' const { data, error } = await tsmart.webhooks.create({
endpoint: "https://hooks.yourdomain.com/tsmart",
events: ["email.delivered", "email.bounced"],
}); webhook = client.webhooks.create({
"endpoint": "https://hooks.yourdomain.com/tsmart",
"events": ["email.delivered", "email.bounced"],
}) Response
{
"object": "webhook",
"id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e",
"signing_secret": "whsec_…"
} The new webhook is enabled.
Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 403 | validation_error | The company already has 10 webhooks. |
| 422 | missing_required_field | endpoint or events is missing. |
| 422 | validation_error | "`endpoint` must be a valid URL" (also for a port other than 443 or 8443, or a URL with a user name or password), "`endpoint` must use https", "`endpoint` must be a public address", or "`events` must list one or more supported event types". |
GET /webhooks/{id}
Returns one webhook with its signing secret.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
Request
curl https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "webhook",
"id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e",
"endpoint": "https://hooks.yourdomain.com/tsmart",
"events": ["email.delivered", "email.bounced"],
"status": "enabled",
"created_at": "2026-10-11T16:20:44.071Z",
"signing_secret": "whsec_…"
} Here created_at is ISO 8601, unlike in the list.
Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No webhook with this id in your company ("Webhook not found"). |
PATCH /webhooks/{id}
Changes a webhook's endpoint, events or status.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
Body
| Name | Type | Description |
|---|---|---|
endpoint | string | A new public HTTPS URL. |
events | string[] | The new list of event types (1 to 20); it replaces the old list. |
status | string | enabled or disabled. Disabling holds the events already queued; events that occur while disabled are not delivered. Enabling resets the failure count and delivers the held events at once. |
Omitted fields keep their value.
Request
curl -X PATCH https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e \
-H "Authorization: Bearer $TSMART_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "events": ["email.delivered", "email.bounced", "email.failed"], "status": "enabled" }' Response
{
"object": "webhook",
"id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e"
} Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No webhook with this id in your company. |
| 422 | validation_error | An invalid endpoint or events (the same messages as on create), or "`status` must be `enabled` or `disabled`". |
DELETE /webhooks/{id}
Deletes a webhook. Nothing more is delivered to it.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
Request
curl -X DELETE https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "webhook",
"id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e",
"deleted": true
} Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No webhook with this id in your company. |
POST /webhooks/{id}/signing-secret/rotate
Replaces the webhook's signing secret.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
Request
curl -X POST https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e/signing-secret/rotate \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "webhook",
"id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e",
"signing_secret": "whsec_…"
} Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No webhook with this id in your company. |
POST /webhooks/{id}/test
Queues an example email.sent event for this webhook, so you can check your receiver.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
Request
curl -X POST https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e/test \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "webhook_event",
"id": "msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL"
}
The event is delivered within seconds, signed and retried like any other, whether or not the webhook subscribes to
email.sent. Follow it with GET …/events/{event_id}.
Its payload:
{
"type": "email.sent",
"created_at": "2026-10-12T10:02:09.513Z",
"data": {
"created_at": "2026-10-12T10:02:09.513Z",
"email_id": "00000000-0000-4000-8000-000000000000",
"from": "Example <hello@example.com>",
"to": ["someone@example.com"],
"subject": "Test event",
"tags": { "test": "true" }
}
} Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No webhook with this id in your company. |
| 422 | validation_error | The webhook is disabled; enable it first. |
| 429 | rate_limit_exceeded | 5 test events of this webhook are still waiting to be delivered. Replay one of them to try it now. |
GET /webhooks/{id}/events
Lists the events delivered, or being delivered, to this webhook, newest first.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
Query
| Name | Type | Description |
|---|---|---|
limit | integer | 1 to 100, default 20. |
after | string | An event id (msg_…) of this webhook: return older events. |
before | string | An event id of this webhook: return newer events. Use either after or before. |
Request
curl "https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e/events?limit=2" \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "list",
"has_more": true,
"data": [
{
"id": "msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL",
"type": "email.bounced",
"created_at": "2026-10-12 09:15:05.120371+00",
"status": "attempting"
},
{
"id": "msg_7pR3sV0wX6yA1bC4dE9fG2hJ5kL",
"type": "email.delivered",
"created_at": "2026-10-12 08:47:19.884602+00",
"status": "success"
}
]
} status is success (delivered), failed (all retries used),
attempting (failed so far, retries left) or pending (not tried yet).
Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No webhook with this id in your company. |
| 422 | validation_error | limit out of range, both after and before, or a cursor that is not an event of this webhook. |
GET /webhooks/{id}/events/{event_id}
Returns one event with the payload that was (or will be) delivered.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
event_id required | string | The event id (msg_…). |
Request
curl https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e/events/msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "webhook_event",
"id": "msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL",
"webhook_id": "c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e",
"type": "email.bounced",
"created_at": "2026-10-12 09:15:05.120371+00",
"status": "attempting",
"attempts": 2,
"next_attempt_at": "2026-10-12 09:20:10.402233+00",
"payload": {
"type": "email.bounced",
"created_at": "2026-10-12T09:15:04.999Z",
"data": {
"created_at": "2026-10-12T09:15:02.431Z",
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fa6f60",
"from": "Acme <hello@yourdomain.com>",
"to": ["jane@example.com"],
"subject": "Welcome to Acme",
"tags": { "category": "welcome" },
"message_id": "<4ef9a417-02e9-4d39-ad75-9611e0fa6f60@yourdomain.com>",
"bounce": { "type": "Permanent", "subType": "General", "message": "550 5.1.1 User unknown" }
}
}
} | Name | Type | Description |
|---|---|---|
status | string | success, failed, attempting or pending, as in the list. |
attempts | integer | How many delivery attempts were made. |
next_attempt_at | string | null | When the next attempt is due; null when none is planned (delivered, out of retries, or the webhook is disabled). |
payload | object | The JSON body that is posted to your endpoint. |
Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No such event for this webhook in your company ("Webhook event not found"). |
GET /webhooks/{id}/events/{event_id}/attempts
Lists the delivery attempts of one event, newest first. The last 50 attempts are kept.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
event_id required | string | The event id (msg_…). |
Query
| Name | Type | Description |
|---|---|---|
limit | integer | 1 to 100, default 20. |
after | string | An attempt id: return older attempts. |
before | string | An attempt id: return newer attempts. Use either after or before. |
Request
curl https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e/events/msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL/attempts \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "list",
"has_more": false,
"data": [
{
"id": "msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL_2",
"http_status_code": 503,
"response": "Service Unavailable",
"error": "HTTP 503",
"duration_ms": 214,
"sent_at": "2026-10-12T09:15:10.402Z"
},
{
"id": "msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL_1",
"http_status_code": null,
"response": "",
"error": "ECONNREFUSED",
"duration_ms": 37,
"sent_at": "2026-10-12T09:15:05.311Z"
}
]
} | Name | Type | Description |
|---|---|---|
id | string | <event id>_<attempt number>. |
http_status_code | integer | null | Your endpoint's status code; null when no answer was received. |
response | string | The first 500 characters of your endpoint's answer, with the endpoint's own path and query removed. |
error | string | null | null for a successful attempt; otherwise "HTTP <status>" or a connection problem such as a network error code or "timeout". |
duration_ms | integer | How long the attempt took. |
sent_at | string | When the attempt finished (ISO 8601). |
Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No such event for this webhook in your company. |
| 422 | validation_error | limit out of range, both after and before, or a cursor that is not a stored attempt of this event. |
POST /webhooks/{id}/events/{event_id}/replay
Delivers an event again, now.
Permission: full access; the key's creator needs the API controls privilege.
Path
| Name | Type | Description |
|---|---|---|
id required | string | The webhook id. |
event_id required | string | The event id (msg_…). |
Request
curl -X POST https://send.tsmart.ai/webhooks/c7d2e9f0-3a18-4b6c-9e27-5f0a1b2c3d4e/events/msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL/replay \
-H "Authorization: Bearer $TSMART_API_KEY" Response
{
"object": "webhook_event",
"id": "msg_2nV8kQ1rT5xZ0bY7cW3dE9fH4jL"
}
Any event can be replayed, whatever its status: it is queued again and tried at once (its status shows
attempting, or pending if it was never tried), with the same id and payload, so your
receiver can recognise a duplicate by webhook-id. If the replay fails, the event continues its retry
schedule where it left off; an event that had already used all its retries is marked
failed again.
Errors
| Status | Name | When |
|---|---|---|
| 401 | restricted_api_key | A sending-access key. |
| 403 | invalid_permission | The key's creator lacks the API controls privilege. |
| 404 | not_found | No such event for this webhook in your company. |
| 422 | validation_error | The webhook is disabled; enable it before replaying events. |