> For the complete documentation index, see [llms.txt](https://docs.cronly.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.cronly.app/service-guides/email-hooks.md).

# Email hooks

Configure an HTTPS receiver on your Cronly email hook. Cronly parses incoming mail, stores the event, and POSTs it to your receiver. JSON is the default; multipart form-data is an optional encoding of the same data.

This guide describes version 1 of the email webhook contract. For cron job and certificate alerts, see [Webhooks](https://docs.cronly.app/setup-guides/webhooks).

### Contract downloads

* [JSON Schema](https://cronly.app/webhooks/email/v1/schema.json) — canonical event fields and validation
* [OpenAPI 3.1](https://cronly.app/webhooks/email/v1/openapi.json) — request headers, encodings and responses
* [Example event](https://cronly.app/webhooks/email/v1/example.json) — synthetic data
* [Node.js receiver helper](https://cronly.app/webhooks/email/v1/receiver.mjs) — signature verification and parsing for both formats

### Configure your receiver

1. Create an HTTPS POST endpoint on port 443, reachable over public IPv4, with a trusted TLS certificate. Redirects are not followed.
2. Save that URL in Cronly. Select JSON unless your receiver requires multipart.
3. Copy the hook’s signing secret into your receiver’s private configuration. Keep it out of browser code and logs.
4. Verify the signature against the raw request bytes, parse the selected format, then validate using the linked schema.
5. Deduplicate by `event_id` and durably store or queue the event before returning a 2xx response. Run application work asynchronously.
6. Use **Send test event** on the hook and inspect the delivery result. Tests consume one monthly event and are limited to five requests per minute.

### Encoding and fields

| Format         | Content-Type                      | Parsing                                                                                                                     |
| -------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| JSON (default) | `application/json`                | Parse the body as a JSON object.                                                                                            |
| Multipart      | `multipart/form-data; boundary=…` | One text part per top-level field. JSON-decode `payload` and `metadata`; convert `account_id` and `monitor_id` to integers. |

The envelope contains `event_id`, `account_id`, `monitor_id`, `monitor_type`, `event_type`, `received_at`, `source`, `status`, `payload` and `metadata`. Email fields are inside `payload`.

`event_type` is `email.received` for real mail or `email.test` for synthetic tests. Tests also have `metadata.test=true`; receivers should avoid triggering real business actions for these events. Outbound `status` remains `received`, including retries. Empty metadata is `[]` for compatibility; otherwise it is an object. This v1 envelope is Cronly-specific, not CloudEvents.

`payload.recipient` is the SMTP envelope recipient used for routing. `from` and `reply_to` are decoded header strings that may include display names. `message_id` is the email’s Message-ID, separate from Cronly’s event UUID. Optional body/header fields may be missing or null. `received_at` is Cronly’s acceptance time, not the sender’s Date header.

**Attachments are metadata only:** name, content\_type and size in bytes. Neither format includes file bytes or download URLs. Original attachment files are not stored and cannot be retrieved by retrying. HTML and all sender-provided content are untrusted; sanitize HTML before rendering.

### Example JSON event

```json
{
  "event_id": "8e2fe6b4-bdda-4a39-bef6-610ad12542b8",
  "account_id": 123,
  "monitor_id": 456,
  "monitor_type": "email",
  "event_type": "email.received",
  "received_at": "2026-09-23T16:00:00+00:00",
  "source": "email",
  "status": "received",
  "payload": {
    "recipient": "example-hook@in.easythings.io",
    "from": "Sender <sender@example.com>",
    "subject": "A report",
    "text": "The report is ready.",
    "attachments": []
  },
  "metadata": []
}
```

### Verify signatures before parsing

Cronly uses the symmetric signing convention from [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). This authenticates delivery from Cronly; it does not authenticate the email’s claimed sender.

* `webhook-id`: stable event UUID
* `webhook-timestamp`: Unix seconds for this attempt
* `webhook-signature`: `v1,<base64 signature>`
* `X-Cronly-Schema-Version`: `1`
* `Idempotency-Key` and `X-Cronly-Event-Id`: same event UUID
* `Link`: canonical JSON Schema URL with `rel="describedby"`

```
key = base64_decode(signing_secret without "whsec_")
signed_bytes = webhook_id + "." + webhook_timestamp + "." + raw_body
expected = base64_encode(HMAC_SHA256(key, signed_bytes))
```

Compare signatures in constant time and reject timestamps more than 300 seconds from your clock. Preserve exact raw bytes, including multipart boundaries and line endings. Do not re-serialize parsed JSON before verification. Match the parsed `event_id` to `webhook-id`. Support space-separated signatures for future key rotation. The per-hook secret is encrypted at rest and available only to users or tokens with write access.

### Delivery, retries and versions

Delivery is at least once; duplicates and out-of-order arrivals are possible. A receiver that already accepted an event should return 2xx again. Cronly allows 3 seconds to connect and 10 seconds total, does not follow redirects, and stops reading responses after 64 KiB. Keep acknowledgements small, ideally 204 with no body.

A non-2xx response or network failure is recorded as failed. **Ordinary failures currently require a manual retry**, with five total attempts by default. Interrupted worker leases recover automatically. Inspect the event’s delivery history in Cronly. Raw response bodies are not retained; keep useful validation details in your receiver logs without recording secrets or email bodies.

Each hook and queued delivery is pinned to schema version 1. Format changes apply to new events; pending deliveries keep their original format. Manual retries use the current destination and format, retain the original contract version and event UUID, and get a fresh signature timestamp. Treat unknown optional fields as forward-compatible; breaking schema changes require a new version.

### Management API

Existing company bearer tokens may manage hooks. Scoped tokens require `email-hooks:read` or `email-hooks:write` as appropriate.

* `POST /api/email-hooks`: name, optional destination\_url, webhook\_format (`json` or `multipart`; default `json`), webhook\_schema\_version (currently only `"1"`).
* `PATCH /api/email-hooks/{id}`: name is required; omitted destination\_url preserves the destination, explicit null clears it. Format is optional. Only version `"1"` is supported.
* `GET /api/email-hooks/{id}`: format, version, documentation/schema links and paginated events. API event status is the current processing state; outbound status stays `received`.
* `GET /api/email-hooks/{id}/signing-secret`: write scope; returns signing\_secret with `Cache-Control: private, no-store`.
* `POST /api/email-hooks/{id}/test`: write scope; queues a synthetic `email.test` event and returns 202 with event\_id. Requires an active hook and destination.
* `POST /api/email-hooks/{id}/events/{event_id}/retry`: write scope; retries an eligible failed delivery using the current destination and format.

[Manage your email hooks](https://cronly.app/email-hooks)
