CarlyEmail docs

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.