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.
Delivery
Non-2xx responses are retried with backoff. Every event is recorded before delivery is attempted, so events remains the source of truth if your endpoint was down.