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.
descriptionstringNoOptional description for this webhook

Request Body

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

Response

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

List all webhook endpoints on your account.

Response

json
{
  "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"],
  "active": true,
  "updated_at": "2026-04-06T09:00:00Z"
}
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", "event": "email.bounced", "status": "failed", "payload": "{"type":"email.bounced",...}" }
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; 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.openedRecipient opened the email (tracking pixel)
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 via one-click List-Unsubscribe (RFC 8058)
email.receivedInbound email was received. This event is reserved until inbound receiving is enabled.
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 through the single-contact API
contact.updatedA contact was updated through the API
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.

Webhook Payload

Each webhook POST contains a JSON payload with the event details:

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

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

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.