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
| Header | Value |
|---|---|
| X-SendFleet-Signature | sha256= followed by the HMAC-SHA256 hex digest of the raw body |
| X-SendFleet-Event | The event name, for example email.delivered |
| Content-Type | application/json |
| User-Agent | SendFleet-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.
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.
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 okThe eight events
| Event | What it means | What to do |
|---|---|---|
| email.sent | The message was accepted for delivery. | Nothing required. |
| email.delivered | The receiving server accepted it. | Mark delivered. |
| email.delayed | Temporary deferral by the receiving server. | Wait. A later event supersedes this one. |
| email.bounced | Permanent failure. | Suppress the address. Check bounce_type first. |
| email.complained | The recipient marked it as spam. | Suppress the address immediately. |
| email.rejected | The send was refused before leaving. | Log it. Nothing was sent. |
| email.suppressed | The address is on the suppression list, so nothing was sent. | Treat as permanent. It counts against quota but not bounce rate. |
| email.failed | The 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.
{
"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.
{
"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.