Webhooks

Get notified in real time when emails, domains, or contacts change.

POST/v1/webhooks

Create a new webhook endpoint. SMTPfast will POST events to this URL.

Parameters

ParameterTypeRequiredDescription
urlstringYesThe HTTPS URL to receive webhook events
eventsstring[]YesArray of event types to subscribe to
formatstringNoDelivery format: "standard" (default), "discord", or "slack". Auto-detected from the URL host if omitted.

Request Body

json
{
  "url": "https://yourapp.com/webhooks/smtpfast",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "format": "standard"
}

Response

json
{
  "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"
}
GET/v1/webhooks

List all webhook endpoints on your account.

Response

json
{
  "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"
    }
  ]
}
PUT/v1/webhooks/:id

Update a webhook endpoint. You can change the URL, subscribed events, or active status.

Parameters

ParameterTypeRequiredDescription
idstringYesThe webhook ID
urlstringNoNew URL for the webhook
eventsstring[]NoUpdated list of subscribed events
activebooleanNoSet to false to pause the webhook

Request Body

json
{
  "events": ["email.delivered", "email.bounced", "email.complained", "email.opened", "email.clicked", "email.unsubscribed"],
  "active": true
}

Response

json
{
  "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
}
POST/v1/webhooks/:id/test

Send a test event to your webhook endpoint. Useful for verifying your integration is working.

Parameters

ParameterTypeRequiredDescription
idstringYesThe webhook ID

Response

json
{
  "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.

GET/v1/webhooks/:id/deliveries

Delivery log of one webhook, newest first. Requires the webhook:read scope.

Parameters

ParameterTypeRequiredDescription
idstringYesThe webhook ID
statusstringNopending, succeeded or failed
limitintegerNo1 to 100, default 20
afterstringNoDelivery id; return older entries

Response

json
{
  "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"
    }
  ]
}
GET/v1/webhooks/:id/deliveries/:delivery_id

One delivery, including the exact payload that was sent. Requires the webhook:read scope.

Parameters

ParameterTypeRequiredDescription
idstringYesThe webhook ID
delivery_idstringYesThe delivery ID

Response

json
{
  "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\"}"
}
POST/v1/webhooks/:id/deliveries/:delivery_id/retry

Send the same payload again, once, right now. Returns the result of that attempt. Requires the webhook:write scope.

A delivery that still has a scheduled attempt returns 409; wait for it or check the log. The signature is computed over the unchanged body, so your endpoint verifies a retry exactly like the first attempt.

Parameters

ParameterTypeRequiredDescription
idstringYesThe webhook ID
delivery_idstringYesThe delivery ID

Response

json
{ "success": true, "message": "Delivered", "delivery": { "id": "whd_01j9x4", "status": "succeeded", "attempts": 6, "status_code": 200 } }

Event Types

EventDescription
email.scheduledEmail was accepted for a future send time
email.sentEmail was accepted by the sending provider for delivery
email.deliveredEmail was accepted by the recipient's mail server
email.delivery_delayedA delivery attempt failed temporarily and SMTPfast will retry
email.bouncedEmail 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.complainedRecipient marked the email as spam
email.openedThe 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.clickedRecipient clicked a tracked link in the email
email.failedEmail failed after the final send attempt
email.suppressedEmail was not sent because every recipient was suppressed
email.unsubscribedRecipient opted out of your marketing email via the unsubscribe link or one-click List-Unsubscribe (RFC 8058)
email.receivedInbound email was received. One event per recipient address, as soon as the message is parsed or quarantined.
domain.createdA sending domain was added
domain.updatedA sending domain's verification state changed
domain.deletedA sending domain was deleted
contact.createdA contact was created with POST /v1/contacts (one contact, not the bulk form)
contact.updatedA contact was changed with PATCH /v1/contacts/:id. See contact events for what does not fire it.
contact.deletedA contact was deleted
contact.subscribedA 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

ChangeEvent
POST /v1/contacts with one contactcontact.created
PATCH /v1/contacts/:idcontact.updated
DELETE /v1/contacts/:idcontact.deleted
A signup through a hosted form, or a confirmed double opt-incontact.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:

json
{
  "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:

json
{
  "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):

json
{
  "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.

bash
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:

text
⚠️ 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:

AttemptDelay
1st retry1 second
2nd retry30 seconds
3rd retry5 minutes
4th retry30 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.

GET/v1/webhooks/{id}/events

List the events sent to a webhook, newest first.

Parameters

ParameterTypeRequiredDescription
statusstringNosuccess, failed, attempting or pending
limitintegerNo1 to 100, default 20
afterstringNoReturn events older than this event id

Response

json
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "cmu1...", "type": "email.delivered", "created_at": "2026-10-01T09:59:59.000Z", "status": "attempting" }
  ]
}
GET/v1/webhooks/{id}/events/{event_id}

One event with the exact payload sent and the time of the next retry.

Response

json
{
  "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" }
}
GET/v1/webhooks/{id}/events/{event_id}/attempts

Every attempt to deliver one event, newest first, with the HTTP status your endpoint returned.

POST/v1/webhooks/{id}/events/{event_id}/replay

Send one event again, now, with the same body. An event with a retry still scheduled returns 409.

POST/v1/webhooks/{id}/signing-secret/rotate

Replace the signing secret. The new secret is in this response only, and every event sent after the call is signed with it.

Owners and admins only. Update your endpoint with the new secret right away; requests signed with it start at once.

Response

json
{
  "object": "webhook",
  "id": "wh_...",
  "signing_secret": "a3f1..."
}