Guide

Verify webhook signatures and handle delivery events

Every webhook request carries an HMAC-SHA256 signature over the raw body. This guide verifies it in five languages, then covers the eight event types and what your handler should do with each one.

The two headers

Webhook request headers
HeaderValue
X-SendFleet-Signaturesha256= followed by the HMAC-SHA256 hex digest of the raw body
X-SendFleet-EventThe event name, for example email.delivered
Content-Typeapplication/json
User-AgentSendFleet-Webhook/1.0

Verify before you process anything

Compute HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret, and compare the hex digest to the header with a constant-time comparison.

Python
import hashlib
import hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    if not header.startswith("sha256="):
        return False
    expected = header[len("sha256="):]
    digest = hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(digest, expected)

A complete handler

The order matters: read the raw body, verify, respond, then work. Anything before the response risks a timeout, and a timeout is a failed delivery that is never retried.

Python, Flask
from flask import Flask, request, Response

app = Flask(__name__)
SECRET = os.environ["SENDFLEET_WEBHOOK_SECRET"]


@app.post("/your-webhook")
def receive():
    raw = request.get_data()          # bytes, before any JSON parsing
    if not verify(raw, request.headers.get("X-SendFleet-Signature"), SECRET):
        return Response("bad signature", status=401)

    event = request.headers.get("X-SendFleet-Event", "")
    # Acknowledge first. There are no retries.
    ok = Response("", status=204)
    handle(event, json.loads(raw))    # then do the work
    return ok

The eight events

Event names and what they mean
EventWhat it meansWhat to do
email.sentThe message was accepted for delivery.Nothing required.
email.deliveredThe receiving server accepted it.Mark delivered.
email.delayedTemporary deferral by the receiving server.Wait. A later event supersedes this one.
email.bouncedPermanent failure.Suppress the address. Check bounce_type first.
email.complainedThe recipient marked it as spam.Suppress the address immediately.
email.rejectedThe send was refused before leaving.Log it. Nothing was sent.
email.suppressedThe address is on the suppression list, so nothing was sent.Treat as permanent. It counts against quota but not bounce rate.
email.failedThe send failed permanently.Log it and alert if it is unexpected.

Bounces: read bounce_type

bounce_type mirrors the SES classification and it decides what you do next. Permanent is a hard bounce and the address is suppressed. Transient is temporary and the address is not suppressed.

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"
}

Complaints are different

A spam complaint is a statement about your list, not a transient condition. Suppress the address straight away rather than waiting to see whether it recurs.

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"
}

Suppression counts against quota

A send to a suppressed address is accepted with HTTP 200 and then not delivered. It resolves to a suppressed status, fires an email.suppressed webhook with a reason, and still counts against your monthly quota. It does not count against your bounce rate, because a skipped delivery is not a bounce. If you see suppressed events climbing, your list has hard bounces in it that you are not handling.

Setting up an endpoint

  • Go to Dashboard → Webhooks → Add endpoint, enter a URL and select the events you want.
  • The signing secret is shown exactly once. Copy it into your environment immediately. It cannot be retrieved later; if you lose it, delete the endpoint and create a new one.
  • Endpoint URLs must be publicly resolvable. Addresses that resolve to private, loopback or link-local ranges are rejected when you save, so localhost will not work. Use a tunnelling service that gives you a public URL for local development.
  • Respond with any 2xx within five seconds.

Next

The Webhooks reference has every field for every event. Suppression and Suspension covers what SendFleet does on its side once an address is suppressed.