CarlyEmail docs
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.