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. Any 2xx response within 10 seconds counts as
delivered. Anything else counts as a failure: another status, a redirect, a
timeout, or no connection. Because a retry can follow a response that reached
your server but not ours, deduplicate work by event_id.
Each webhook is delivered on its own. If one of your endpoints fails, your other endpoints still get the event right away, and they do not get it again when the failed one is retried.
Retries
A failed delivery is tried again after about:
| Retry | Wait after the previous attempt |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
| 6 | 10 hours |
That is seven attempts over about 18 hours. Each wait varies by up to 20%. After the last attempt the event is not sent again. You can still read it from Events, which is also how to catch up on anything missed.
Some responses are not retried in full:
| Response | What happens |
|---|---|
410 Gone |
No retries. The webhook is turned off. |
400, 401, 403, 404, 405, 413, 414, 415, 422, or any 3xx |
Two retries, after 1 and 5 minutes |
429 or 503 with Retry-After |
Retried on the schedule, but not before Retry-After (up to one hour) |
| Anything else | The full schedule |
Rate limits
We send each endpoint at most 10 requests per second and at most 3 at a time. Events beyond that wait a few seconds. Waiting does not use up an attempt.
After 3 failures in a row, we pause the endpoint. New events for it wait
instead of being sent, starting with a 1-minute pause that grows along the same
schedule as retries. When a pause ends we send one event. If it succeeds,
everything else follows. A Retry-After header on a 429 or 503 pauses the
endpoint for that long, up to one hour.
Turned-off webhooks
If every delivery to an endpoint fails for 24 hours, or it answers 410 Gone,
we turn the webhook off. The webhook then shows enabled: false, with the reason
in disabled_reason. Events that were waiting for it are not sent. To turn it
back on:
curl -X PATCH https://api.carlyemail.com/v0/webhooks/$WEBHOOK_ID \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{"enabled": true}'
GET /v0/webhooks/{webhook_id}/attempts lists every attempt, with the status
and the start of the response body for failures.
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.