Events

Webhooks

Receive real-time notifications as delivery events happen, sent, delivered, bounced, complained, suppressed, rejected, delayed, or failed. Every request is signed with HMAC-SHA256 so you can confirm it came from SendFleet.

How webhooks work

When a delivery event occurs, SendFleet sends an HTTP POST to your endpoint with a JSON body describing it. Your endpoint should respond with any 2xx status within 5 seconds.

Creating an endpoint

Go to Dashboard → Webhooks → Add endpoint, enter a URL, and select the events you want. A signing secret is generated and shown once.

Endpoint URLs must be publicly resolvable. Addresses that resolve to private, loopback, or link-local ranges are rejected when you save the endpoint, which rules out localhost and internal hostnames. For local development, use a tunnelling service that gives you a public URL.

Signature verification

Every request carries an X-SendFleet-Signature header: an HMAC-SHA256 hex digest of the raw request body, keyed with your endpoint's signing secret and prefixed with sha256=. Verify it before you process anything.

Request headers

Webhook request headers
POST /your-webhook HTTP/1.1
Content-Type: application/json
X-SendFleet-Signature: sha256=a3f2b1c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1
X-SendFleet-Event: email.delivered
User-Agent: SendFleet-Webhook/1.0
Webhook request headers
HeaderDescription
X-SendFleet-Signaturesha256= followed by the HMAC-SHA256 hex digest of the raw body.
X-SendFleet-EventThe event name, for example email.delivered. This is the only place the event name appears — it is not in the body.
User-AgentAlways SendFleet-Webhook/1.0.

Verification examples

Python
import hmac
import hashlib
import json
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_signing_secret"


@app.post("/webhook")
def webhook():
    # Sign the RAW body. Parsing to JSON and re-serialising changes the bytes
    # and the signature will never match.
    raw = request.get_data()

    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(),
        raw,
        hashlib.sha256,
    ).hexdigest()

    signature = request.headers.get("X-SendFleet-Signature", "")
    if not hmac.compare_digest(expected, signature):
        abort(401)

    # The event name is in the header, not the body.
    event_type = request.headers.get("X-SendFleet-Event", "")
    payload = json.loads(raw)

    if event_type == "email.delivered":
        mark_delivered(payload["idempotency_key"])
    elif event_type in ("email.bounced", "email.complained", "email.suppressed"):
        mark_undeliverable(payload["recipient"])

    return "", 200

Checking one delivery from the shell

When an endpoint is rejecting deliveries and you cannot tell whether the signature or the handler is at fault, capture one body and recompute its digest with openssl:

Verify a signature from the command line
# Save the body exactly as it arrived.
curl -X POST https://example.com/your-webhook \
  -H "Content-Type: application/json" \
  -H "X-SendFleet-Signature: sha256=<digest>" \
  --data-binary @payload.json payload.json

# Sign those same bytes with the endpoint's signing secret.
openssl dgst -sha256 -hmac "$SENDFLEET_WEBHOOK_SECRET" -hex payload.json
# SHA2-256(payload.json)= <the same digest, without the sha256= prefix>

Event types

Choose which events an endpoint receives when you create it. Events are scoped per endpoint, so you can route different subsets to different services.

Available webhook events
EventFired when
email.sentThe delivery platform has accepted the message. This is the first event for a successful send and carries the ses_message_id.
email.deliveredThe recipient's mail server confirmed delivery. Usually within seconds for healthy destinations.
email.bouncedThe recipient's mail server rejected the message. Permanent bounces also suppress the address.
email.complainedThe recipient marked the message as spam. The address is suppressed automatically.
email.suppressedDelivery was skipped because the recipient is already suppressed. This replaces the old request-time 422, the send is accepted, then resolves here. Does not count towards your measured bounce rate.
email.rejectedThe message was declined before any delivery attempt. The reason is included.
email.delayedDelivery is taking longer than normal but has not failed. The message may still arrive, so this is informational — do not treat it as terminal.
email.failedThe message could not be sent because of a permanent error such as a rendering failure. It was not delivered.

Payload reference

Correlation fields

Fields present on every webhook
FieldDescription
idempotency_keyThe SendFleet key for this send. It is the same value returned as idempotency_key on the POST /v1/send response, so it is how you tie webhooks back to the request that created them. Use this as your deduplication key.
ses_message_idThe message ID assigned by the delivery platform, named ses_message_id for compatibility. Stable across redelivery, and the field to quote when contacting support about a specific send. Empty on email.suppressed and email.failed events raised before the message was handed off.
recipientThe recipient this event is about. A send to several recipients (to, cc, bcc) produces one event per recipient, all under the same idempotency_key, so correlate on idempotency_key and recipient together.
statusThe resulting status: sent, delivered, bounced, complained, suppressed, rejected, delayed, or failed.

email.sent

email.sent payload
{
  "idempotency_key": "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":  "010f0192a1b2c3d4-5e6f7890-abcd-ef12-3456-7890abcdef12-000000",
  "recipient":       "alice@example.com",
  "subject":         "Your order has shipped",
  "status":          "sent",
  "mode":            "shared",
  "attachments":     1
}

email.delivered

email.delivered payload
{
  "idempotency_key":        "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":         "010f0192a1b2c3d4-5e6f7890-abcd-ef12-3456-7890abcdef12-000000",
  "recipient":              "alice@example.com",
  "status":                 "delivered",
  "smtp_response":          "250 2.0.0 OK  1699999999 x1si0000000qtx.1 - gsmtp",
  "processing_time_millis": 1284,
  "recipients":             ["alice@example.com"]
}

email.bounced

email.bounced payload
{
  "idempotency_key":    "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":     "010f0192a1b2c3d4-5e6f7890-abcd-ef12-3456-7890abcdef12-000000",
  "recipient":          "alice@example.com",
  "status":             "bounced",
  "bounce_type":        "Permanent",
  "bounce_subtype":     "General",
  "bounced_recipients": ["alice@example.com"],
  "timestamp":          "2026-05-14T09:31:02.000Z"
}

bounce_type mirrors the SES classification: Permanent is a hard bounce and the address is suppressed; Transient is temporary and the address is not suppressed.

email.complained

email.complained payload
{
  "idempotency_key":          "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":           "010f0192a1b2c3d4-5e6f7890-abcd-ef12-3456-7890abcdef12-000000",
  "recipient":                "alice@example.com",
  "status":                   "complained",
  "complaint_feedback_type":  "abuse",
  "complained_recipients":    ["alice@example.com"],
  "timestamp":                "2026-05-14T10:02:47.000Z"
}

email.suppressed

email.suppressed payload
{
  "idempotency_key": "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":  "",
  "recipient":       "alice@example.com",
  "subject":         "Your order has shipped",
  "status":          "suppressed",
  "mode":            "shared",
  "reason":          "Message rejected: the recipient address is suppressed"
}

email.rejected

email.rejected payload
{
  "idempotency_key": "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":  "010f0192a1b2c3d4-5e6f7890-abcd-ef12-3456-7890abcdef12-000000",
  "recipient":       "alice@example.com",
  "status":          "rejected",
  "reason":          "Email rejected due to virus detection"
}

email.delayed

email.delayed payload
{
  "idempotency_key":    "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":     "010f0192a1b2c3d4-5e6f7890-abcd-ef12-3456-7890abcdef12-000000",
  "recipient":          "alice@example.com",
  "status":             "delayed",
  "delay_type":         "MailboxFull",
  "expiration_time":    "2026-05-15T09:30:00.000Z",
  "delayed_recipients": ["alice@example.com"]
}

email.failed

email.failed payload
{
  "idempotency_key": "8f14e45fceea167a5a36dedd4bea2543",
  "ses_message_id":  "",
  "recipient":       "alice@example.com",
  "subject":         "Your order has shipped",
  "status":          "failed",
  "mode":            "shared",
  "reason":          "Message rejected: permanent failure"
}

Endpoint requirements

What your endpoint must do
RequirementDetail
ProtocolHTTPS or HTTP. Use HTTPS in production — the payload contains recipient addresses.
ReachabilityThe hostname must resolve to a public address. Private, loopback, and link-local addresses are rejected when the endpoint is saved.
Response codeAny 2xx. Anything else is logged as a failure and the event is not resent.
Response timeUnder 5 seconds. Slower responses are treated as failures.
IdempotencyDeduplicate on idempotency_key. The same event can arrive more than once.
Signature checkAlways verify X-SendFleet-Signature against the raw body before doing anything else.

Best practices

  1. Verify before you parse

    Check X-SendFleet-Signature against the raw body first, before parsing JSON or touching your database. Return 401 immediately on a mismatch, an unverified payload is untrusted input.
  2. Acknowledge fast, process afterwards

    Return 200 as soon as you have verified and stored the raw event, then do the real work on a background queue. With no retries and a 5-second timeout, slow handlers lose events outright.
  3. Deduplicate on idempotency_key

    Store processed keys and skip anything you have seen. Note that one send produces several events, so key your deduplication on idempotency_key plus the event name, not the key alone.
  4. Act on bounces, complaints, and suppressions

    On email.bounced with bounce_type: "Permanent", on email.complained, and on email.suppressed, mark the address undeliverable in your own database. Suppression on our side stops the send, but only your list hygiene stops you re-adding the address later.
  5. Treat delayed as informational

    email.delayed is not a failure. The message may still be delivered, and a email.delivered event may follow. Do not mark anything undeliverable on a delay alone.

Managing endpoints

From Dashboard → Webhooks you can view, activate, deactivate, or delete endpoints. Deactivating stops delivery without losing the configuration, which is useful for maintenance windows. Deleting is permanent, and events that occur while an endpoint is inactive are not queued or replayed afterwards.

Frequently asked questions

Signature verification, the full event list, plan availability, and retry behaviour.

How do I verify a SendFleet webhook signature?
Compute HMAC-SHA256 of the raw request body using your endpoint's signing secret as the key, prefix the hex digest with sha256=, and compare it to the X-SendFleet-Signature header using a constant-time comparison. Sign the raw bytes: parsing the JSON and re-serialising it changes the body and the signature will never match.
What webhook events does SendFleet support?
SendFleet fires eight event types: email.sent, email.delivered, email.bounced, email.complained, email.suppressed, email.rejected, email.delayed and email.failed. The event name is sent in the X-SendFleet-Event header rather than in the body, and every payload is flat with idempotency_key as the field to correlate on.
Are SendFleet webhooks available on all plans?
Webhooks are available on the BYOC plan and on all three Managed plans: Managed Starter, Managed Growth and Managed Pro. On BYOC they are built from your own SES account's delivery events, so delivery tracking must be on (Dashboard → AWS), and each payload carries "mode": "byoc". Free BYOC has no webhooks at all: none fire for its BYOC sends or for its Shared smoke tests, even if the account has endpoints left over from a previous paid plan.
Does SendFleet retry a failed webhook delivery?
No. Deliveries time out after 5 seconds and failed deliveries are not retried. Return a 2xx quickly and hand any real work to a queue of your own, or you will lose events.