Webhooks
Get notified in real time when emails, domains, or contacts change.
/v1/webhooksCreate a new webhook endpoint. SMTPfast will POST events to this URL.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | The HTTPS URL to receive webhook events |
| events | string[] | Yes | Array of event types to subscribe to |
| format | string | No | Delivery format: "standard" (default), "discord", or "slack". Auto-detected from the URL host if omitted. |
Request Body
{
"url": "https://yourapp.com/webhooks/smtpfast",
"events": ["email.delivered", "email.bounced", "email.complained"],
"format": "standard"
}Response
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhooks/smtpfast",
"events": ["email.delivered", "email.bounced", "email.complained"],
"format": "standard",
"secret": "3f9a7c1e5b2d8a4f6e0c9b7d5a3e1f2c4b6d8a0e2c4f6a8b0d2e4f6a8c0b2d4e",
"active": true,
"created_at": "2026-04-05T12:00:00Z"
}/v1/webhooksList all webhook endpoints on your account.
Response
{
"object": "list",
"has_more": false,
"data": [
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhooks/smtpfast",
"events": ["email.delivered", "email.bounced", "email.complained"],
"active": true,
"created_at": "2026-04-05T12:00:00Z"
}
]
}/v1/webhooks/:idUpdate a webhook endpoint. You can change the URL, subscribed events, or active status.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The webhook ID |
| url | string | No | New URL for the webhook |
| events | string[] | No | Updated list of subscribed events |
| active | boolean | No | Set to false to pause the webhook |
Request Body
{
"events": ["email.delivered", "email.bounced", "email.complained", "email.opened", "email.clicked", "email.unsubscribed"],
"active": true
}Response
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhooks/smtpfast",
"events": ["email.delivered", "email.bounced", "email.complained", "email.opened", "email.clicked", "email.unsubscribed"],
"format": "standard",
"active": true
}/v1/webhooks/:id/testSend a test event to your webhook endpoint. Useful for verifying your integration is working.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The webhook ID |
Response
{
"success": true,
"status": 200,
"message": "Test webhook delivered successfully",
"delivery": { "id": "whd_01j9x4", "event": "test", "status": "succeeded", "attempts": 1, "status_code": 200, "duration_ms": 142 }
}Delivery log and retries
Every event we POST to a webhook is recorded with its attempts: status code, response time, the first 500 characters of the response, and the error when there was one. Failed deliveries are retried on their own after 1 second, 30 seconds, 5 minutes and 30 minutes (5 attempts in total). After that the delivery is marked failed and stays in the log for 30 days. You can see the log under each webhook in the dashboard (Deliveries) and retry any entry from there or through the API.
/v1/webhooks/:id/deliveriesDelivery log of one webhook, newest first. Requires the webhook:read scope.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The webhook ID |
| status | string | No | pending, succeeded or failed |
| limit | integer | No | 1 to 100, default 20 |
| after | string | No | Delivery id; return older entries |
Response
{
"object": "list",
"has_more": false,
"data": [
{
"object": "webhook_delivery",
"id": "whd_01j9x4",
"webhook_id": "wh_abc123",
"event": "email.bounced",
"status": "failed",
"attempts": 5,
"max_attempts": 5,
"status_code": 503,
"duration_ms": 87,
"error": "Endpoint returned 503",
"response": "upstream unavailable",
"attempt_log": [{ "at": "2026-09-03T08:00:00.000Z", "status_code": 503, "duration_ms": 91, "error": "Endpoint returned 503" }],
"next_attempt_at": null,
"delivered_at": null,
"created_at": "2026-09-03T08:00:00.000Z"
}
]
}/v1/webhooks/:id/deliveries/:delivery_idOne delivery, including the exact payload that was sent. Requires the webhook:read scope.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The webhook ID |
| delivery_id | string | Yes | The delivery ID |
Response
{
"object": "webhook_delivery",
"id": "whd_01j9x4",
"webhook_id": "wh_abc123",
"event": "email.bounced",
"status": "failed",
"payload": "{\"type\":\"email.bounced\",\"data\":{\"email_id\":\"email_abc123\",\"from\":\"[email protected]\",\"to\":[\"[email protected]\"],\"bounce_type\":\"Permanent\",\"bounce_sub_type\":\"General\",\"refused_by_account_suppression_list\":false},\"timestamp\":\"2026-09-03T08:00:00.000Z\"}"
}/v1/webhooks/:id/deliveries/:delivery_id/retrySend the same payload again, once, right now. Returns the result of that attempt. Requires the webhook:write scope.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The webhook ID |
| delivery_id | string | Yes | The delivery ID |
Response
{ "success": true, "message": "Delivered", "delivery": { "id": "whd_01j9x4", "status": "succeeded", "attempts": 6, "status_code": 200 } }Event Types
| Event | Description |
|---|---|
| email.scheduled | Email was accepted for a future send time |
| email.sent | Email was accepted by the sending provider for delivery |
| email.delivered | Email was accepted by the recipient's mail server |
| email.delivery_delayed | A delivery attempt failed temporarily and SMTPfast will retry |
| email.bounced | Email bounced (hard bounce or soft bounce). Payload has bounce_type, bounce_sub_type and refused_by_account_suppression_list. bounce_type is Amazon SES's value: Permanent, Transient or Undetermined. bounce_sub_type is SES's too, for example General, NoEmail, Suppressed or OnAccountSuppressionList for Permanent, and MailboxFull, MessageTooLarge, ContentRejected or AttachmentRejected for Transient. refused_by_account_suppression_list the last is true when Amazon SES refused the send because the address is on its account-level suppression list, which is not a bounce from the recipient's server and also fires email.failed |
| email.complained | Recipient marked the email as spam |
| email.opened | The tracking pixel was loaded. Gmail and Apple Mail fetch images through a proxy, often seconds after delivery, so an open does not always mean a person read the email. Treat open rates as a trend, not a count of readers. |
| email.clicked | Recipient clicked a tracked link in the email |
| email.failed | Email failed after the final send attempt |
| email.suppressed | Email was not sent because every recipient was suppressed |
| email.unsubscribed | Recipient opted out of your marketing email via the unsubscribe link or one-click List-Unsubscribe (RFC 8058) |
| email.received | Inbound email was received. One event per recipient address, as soon as the message is parsed or quarantined. |
| domain.created | A sending domain was added |
| domain.updated | A sending domain's verification state changed |
| domain.deleted | A sending domain was deleted |
| contact.created | A contact was created with POST /v1/contacts (one contact, not the bulk form) |
| contact.updated | A contact was changed with PATCH /v1/contacts/:id. See contact events for what does not fire it. |
| contact.deleted | A contact was deleted |
| contact.subscribed | A contact subscribed through a hosted form or confirmation flow |
SMTPfast stores and sends Resend-style prefixed email event names. Legacy unprefixed setup requests such as delivered are accepted and normalized to email.delivered.
Contact events: what fires them
| Change | Event |
|---|---|
| POST /v1/contacts with one contact | contact.created |
| PATCH /v1/contacts/:id | contact.updated |
| DELETE /v1/contacts/:id | contact.deleted |
| A signup through a hosted form, or a confirmed double opt-in | contact.subscribed |
| The recipient clicks the unsubscribe link or uses one-click List-Unsubscribe (marketing email only) | email.unsubscribed |
POST /v1/contacts with a contacts array (bulk upsert) | none |
A recipient who unsubscribes themselves fires email.unsubscribed, with email_id, from and to, and the email gets unsubscribed_at. It fires once, and only when the opt-out can be tied to an email sent to that address in the last 30 days. It does not fire contact.updated. Every self-unsubscribe, tied to an email or not, also adds a suppression with reason unsubscribe, which the contact object returns as suppression. A new one has scope: "marketing": it stops marketing email, which includes unlabelled email, and email you label transactional still reaches the address. Opt-outs recorded before marketing-only unsubscribes existed have scope: "all" and block every email.
A contact.updated payload:
{
"type": "contact.updated",
"timestamp": "2026-10-10T12:00:00.000Z",
"data": {
"id": "clx_contact123",
"email": "[email protected]",
"first_name": "Steve",
"last_name": "Wozniak",
"unsubscribed": true,
"properties": { "company": "Acme" },
"segment_ids": ["seg_product"],
"updated_at": "2026-10-10T12:00:00.000Z"
}
}segment_ids is present only when the request changed segments.
Webhook Payload
Each webhook POST contains a JSON payload with the event details:
{
"type": "email.delivered",
"timestamp": "2026-04-05T12:00:03.000Z",
"data": {
"email_id": "email_abc123",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome!"
}
}The unsubscribed event fires when a recipient uses the one-click List-Unsubscribe header (sent on marketing email only) or the unsubscribe link in an email you sent within the last 30 days. The payload ties the opt-out back to the originating send so you can automate downstream actions (e.g. removing the contact from a CRM):
{
"type": "email.unsubscribed",
"timestamp": "2026-04-06T08:14:22.000Z",
"data": {
"email_id": "email_abc123",
"from": "[email protected]",
"to": ["[email protected]"]
}
}The originating email's delivery status stays at its terminal value (typically delivered), because an unsubscribe is something the recipient did after delivery, not a delivery outcome. If a recipient clicks a stale unsubscribe link from a send older than 30 days the suppression still takes effect, but no webhook fires because there is no email to attribute it to.
Send to Discord or Slack
Set format to discord or slack and SMTPfast posts a formatted, color-coded message to your channel instead of the standard JSON envelope. No relay or glue code: paste the channel's incoming webhook URL and pick the format (we also auto-detect discord.com and hooks.slack.com URLs for you). Because the chat platform owns the payload schema, these deliveries are not HMAC-signed.
Get a webhook URL: Discord → Server Settings → Integrations → Webhooks. Slack → create an app → Incoming Webhooks.
curl -X POST https://smtpfa.st/api/v1/webhooks \
-H "Authorization: Bearer $SMTPFAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://discord.com/api/webhooks/123456789/your-token",
"events": ["email.bounced", "email.complained", "email.failed"],
"format": "discord"
}'A bounce then lands in the channel as an embed like:
⚠️ Email bounced To: [email protected] From: [email protected] Bounce type: Permanent SMTPfast
HMAC Signature Verification
Every webhook request includes a X-SMTPfast-Signature header. Verify this signature to ensure the request came from SMTPfast and was not tampered with.
The signature is an HMAC-SHA256 hash of the raw request body using your webhook signing secret.
import crypto from "crypto";
function verifyWebhook(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your webhook handler:
const isValid = verifyWebhook(
rawBody,
req.headers["x-smtpfast-signature"],
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}Retry Strategy
If your endpoint returns a non-2xx status code, redirects, or does not answer within 10 seconds, SMTPfast retries the same body:
| Attempt | Delay |
|---|---|
| 1st retry | 1 second |
| 2nd retry | 30 seconds |
| 3rd retry | 5 minutes |
| 4th retry | 30 minutes |
After the last retry the delivery is marked failed and stays in the log for 30 days. The webhook itself is not paused; you can retry any failed delivery from the dashboard or the API. Each attempt carries a fresh X-SMTPfast-Timestamp; the signature covers the body, which does not change between attempts, so a retry verifies exactly like the first attempt. Requests do not follow redirects.
Events, replay and secret rotation
These endpoints use the same paths and shapes as Resend's webhook API. An event is one payload sent to one webhook, with every delivery attempt recorded. Status is success, failed, attempting (a retry is scheduled) or pending.
/v1/webhooks/{id}/eventsList the events sent to a webhook, newest first.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | No | success, failed, attempting or pending |
| limit | integer | No | 1 to 100, default 20 |
| after | string | No | Return events older than this event id |
Response
{
"object": "list",
"has_more": false,
"data": [
{ "id": "cmu1...", "type": "email.delivered", "created_at": "2026-10-01T09:59:59.000Z", "status": "attempting" }
]
}/v1/webhooks/{id}/events/{event_id}One event with the exact payload sent and the time of the next retry.
Response
{
"object": "webhook_event",
"id": "cmu1...",
"type": "email.delivered",
"status": "attempting",
"next_attempt_at": "2026-10-01T10:05:00.000Z",
"payload": { "type": "email.delivered", "data": { "email_id": "cmu0..." }, "timestamp": "2026-10-01T09:59:59.000Z" }
}/v1/webhooks/{id}/events/{event_id}/attemptsEvery attempt to deliver one event, newest first, with the HTTP status your endpoint returned.
/v1/webhooks/{id}/events/{event_id}/replaySend one event again, now, with the same body. An event with a retry still scheduled returns 409.
/v1/webhooks/{id}/signing-secret/rotateReplace the signing secret. The new secret is in this response only, and every event sent after the call is signed with it.
Response
{
"object": "webhook",
"id": "wh_...",
"signing_secret": "a3f1..."
}