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
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
| Header | Description |
|---|---|
| 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. This is the only place the event name appears — it is not in the body. |
| User-Agent | Always SendFleet-Webhook/1.0. |
Verification examples
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 "", 200Checking 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:
# 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.
| Event | Fired when |
|---|---|
| email.sent | The delivery platform has accepted the message. This is the first event for a successful send and carries the ses_message_id. |
| email.delivered | The recipient's mail server confirmed delivery. Usually within seconds for healthy destinations. |
| email.bounced | The recipient's mail server rejected the message. Permanent bounces also suppress the address. |
| email.complained | The recipient marked the message as spam. The address is suppressed automatically. |
| email.suppressed | Delivery 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.rejected | The message was declined before any delivery attempt. The reason is included. |
| email.delayed | Delivery 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.failed | The message could not be sent because of a permanent error such as a rendering failure. It was not delivered. |
Payload reference
Correlation fields
| Field | Description |
|---|---|
| idempotency_key | The 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_id | The 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. |
| recipient | The 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. |
| status | The resulting status: sent, delivered, bounced, complained, suppressed, rejected, delayed, or failed. |
email.sent
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
| Requirement | Detail |
|---|---|
| Protocol | HTTPS or HTTP. Use HTTPS in production — the payload contains recipient addresses. |
| Reachability | The hostname must resolve to a public address. Private, loopback, and link-local addresses are rejected when the endpoint is saved. |
| Response code | Any 2xx. Anything else is logged as a failure and the event is not resent. |
| Response time | Under 5 seconds. Slower responses are treated as failures. |
| Idempotency | Deduplicate on idempotency_key. The same event can arrive more than once. |
| Signature check | Always verify X-SendFleet-Signature against the raw body before doing anything else. |
Best practices
Verify before you parse
CheckX-SendFleet-Signatureagainst the raw body first, before parsing JSON or touching your database. Return401immediately on a mismatch, an unverified payload is untrusted input.Acknowledge fast, process afterwards
Return200as 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.Deduplicate on idempotency_key
Store processed keys and skip anything you have seen. Note that one send produces several events, so key your deduplication onidempotency_keyplus the event name, not the key alone.Act on bounces, complaints, and suppressions
Onemail.bouncedwithbounce_type: "Permanent", onemail.complained, and onemail.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.Treat delayed as informational
email.delayedis not a failure. The message may still be delivered, and aemail.deliveredevent 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.