Webhooks
Events pushed to your endpoint, and how to verify them.
curl -X POST https://api.carlyemail.com/v0/webhooks \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{
"url": "https://yourapp.com/hooks/carlyemail",
"event_types": ["message.received", "message.bounced"]
}'
Omit event_types for everything. Filter by inbox_ids or pod_ids to narrow
which mail triggers it.
The response includes a signing secret beginning whsec_. It is shown once.
Verifying
Deliveries carry three headers:
webhook-id: evt_00ms93lsavnm83rw519vzpgw
webhook-timestamp: 1785519472
webhook-signature: v1,PkTVSIFEdaKeprYRdLra09HLPZii1lQ846BsY5tigYw=
Sign {id}.{timestamp}.{body} with HMAC-SHA256, using the base64-decoded secret
(without the whsec_ prefix) as the key, and base64-encode the result.
import base64, hmac, hashlib
def verify(secret, webhook_id, timestamp, body, signature):
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{webhook_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return hmac.compare_digest(f"v1,{expected}", signature)
This is the Svix scheme, so an existing Svix verifier works unchanged. Both
svix-* and webhook-* header spellings are sent.
Warning
Reject deliveries whose timestamp is more than a few minutes old, or a captured delivery can be replayed at you later.
Large payloads
Above 1 MB the text and html fields are dropped and the payload is flagged
truncated, listing which fields went. Metadata always survives — fetch the full
message by id.
Event body
The body uses the same event contract as AgentMail's WebSocket and webhook clients:
{
"type": "event",
"event_type": "message.received",
"event_id": "evt_00ms93lsavnm83rw519vzpgw",
"message": {
"inbox_id": "support@carlyemail.com",
"message_id": "<message@example.org>",
"thread_id": "thd_...",
"subject": "Need help",
"text": "..."
},
"thread": { "thread_id": "thd_..." }
}
Other event types place their entity under send, delivery, bounce,
complaint, reject, or domain.
Delivery
Delivery is at least once. A non-2xx response or connection failure is retried
with stream backoff up to five times and for no longer than 24 hours. Exhausted
events enter a dead-letter queue and alert the operator. Because a retry can
follow a response that reached your server but not ours, deduplicate work by
event_id.
The committed event log itself triggers delivery, so there is no successful event write followed by a separate enqueue that can fail. Events remains the source of truth and the manual backfill path.