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. |
| description | string | No | Optional description for this webhook |
Request Body
{
"url": "https://yourapp.com/webhooks/smtpfast",
"events": ["email.delivered", "email.bounced", "email.complained"],
"format": "standard",
"description": "Production delivery events"
}Response
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhooks/smtpfast",
"events": ["email.delivered", "email.bounced", "email.complained"],
"format": "standard",
"description": "Production delivery events",
"signing_secret": "whsec_abc123...",
"active": true,
"created_at": "2026-04-05T12:00:00Z"
}/v1/webhooksList all webhook endpoints on your account.
Response
{
"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"],
"active": true,
"updated_at": "2026-04-06T09:00:00Z"
}/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", "event": "email.bounced", "status": "failed", "payload": "{"type":"email.bounced",...}" }/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; 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 | Recipient opened the email (tracking pixel) |
| 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 via one-click List-Unsubscribe (RFC 8058) |
| email.received | Inbound email was received. This event is reserved until inbound receiving is enabled. |
| 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 through the single-contact API |
| contact.updated | A contact was updated through the API |
| 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.
Webhook Payload
Each webhook POST contains a JSON payload with the event details:
{
"id": "evt_abc123",
"type": "email.delivered",
"email_id": "email_abc123",
"to": "[email protected]",
"timestamp": "2026-04-05T12:00:03Z",
"data": {
"smtp_response": "250 OK"
}
}The unsubscribed event fires when a recipient clicks the one-click List-Unsubscribe header on 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):
{
"id": "evt_abc123",
"type": "email.unsubscribed",
"email_id": "email_abc123",
"to": "[email protected]",
"timestamp": "2026-04-06T08:14:22Z",
"data": {
"from": "[email protected]",
"source": "list-unsubscribe"
}
}The originating email's delivery status stays at its terminal value (typically delivered) — an unsubscribe is a post-delivery recipient action, 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.