# Webhook endpoints

> Subscribe to AY Inbox platform events and receive HMAC-signed delivery callbacks at your own HTTPS endpoints.

Source: https://docs.ayinbox.com/api/reference/webhooks/

Webhooks let your systems react to AY Inbox activity in real time. You register one or more HTTPS endpoints, optionally filter them to specific event types, and AY Inbox POSTs a signed JSON payload to each matching endpoint whenever a subscribed event fires. Every delivery is signed with HMAC-SHA256 and carries a unique event id so you can verify authenticity and dedupe replays. Failed deliveries are retried with exponential backoff and their history is queryable for debugging.

All webhook management endpoints are org-scoped and share one auth gate: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. The signing secret is server-generated and only returned at create and rotate time, so capture it immediately.

List webhook endpoints [#list-webhook-endpoints]

`GET /webhooks`

Returns every endpoint configured for the caller's organization. Secrets are never included in this response.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

Response [#response]

Returns an object with the configured `endpoints` array. This is not a `data` + `pagination` envelope.

```json
{
  "endpoints": [
    {
      "id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
      "organization_id": "11111111-2222-3333-4444-555555555555",
      "url": "https://hooks.example.com/ayinbox",
      "description": "Production event sink",
      "event_types": ["campaign.reply_received", "meeting.booked"],
      "enabled": true,
      "verified_at": "2026-06-11T18:02:14Z",
      "ownership_confirmed": true,
      "last_success_at": "2026-06-11T18:02:14Z",
      "last_failure_at": null,
      "last_failure_reason": null,
      "consecutive_failures": 0,
      "created_at": "2026-05-01T09:00:00Z",
      "updated_at": "2026-06-11T18:02:14Z"
    }
  ]
}
```

List available event types [#list-available-event-types]

`GET /webhooks/event-types`

Returns the canonical catalog of every event AY Inbox can emit. Use this to build an event-type picker or validate `event_types` values before writing them.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

Response [#response-1]

```json
{
  "event_types": [
    {
      "type": "campaign.reply_received",
      "category": "campaign",
      "description": "A human reply lands for a campaign thread.",
      "firehose": false,
      "deprecated": false
    },
    {
      "type": "campaign.email_sent",
      "category": "campaign",
      "description": "A campaign email is dispatched by a worker.",
      "firehose": true,
      "deprecated": false
    }
  ]
}
```

`firehose: true` means the event is high-volume. An endpoint with an empty `event_types` list does **not** receive firehose events automatically. To receive them you must list each one explicitly in `event_types`. See [Firehose events](#firehose-events) for details.

`deprecated: true` means AY Inbox never delivers the event. It stays a valid `event_types` value and automation trigger, so an existing subscription keeps saving, but nothing arrives for it and an empty `event_types` list does not include it. Its `description` names what to use instead. See [Deprecated events](#deprecated-events).

Create a webhook endpoint [#create-a-webhook-endpoint]

`POST /webhooks`

Creates a new subscription. The response is the only time the signing `secret` is returned, so store it immediately. The URL must be HTTPS and resolve to a publicly routable host. Loopback, private, and link-local targets are rejected unless the server runs with unsafe webhook URLs enabled for local or self-hosted development.

New endpoints start in an **unverified** state. They receive a single signed challenge request and no real events until verification passes. See [Endpoint verification](#endpoint-verification).

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

Request body [#request-body]

| Field         | Type      | Required | Description                                                                                                              |
| ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `url`         | string    | Yes      | HTTPS URL that will receive POST callbacks. Must be publicly routable.                                                   |
| `description` | string    | No       | Free-text label for your own reference.                                                                                  |
| `event_types` | string\[] | No       | Event names to subscribe to. Each must be a known type. An empty or omitted array subscribes to all non-firehose events. |
| `enabled`     | boolean   | No       | Whether the endpoint is active. Defaults to `true`.                                                                      |

```json
{
  "url": "https://hooks.example.com/ayinbox",
  "description": "Production event sink",
  "event_types": ["campaign.reply_received", "meeting.booked"],
  "enabled": true
}
```

Response [#response-2]

`201 Created` with the full endpoint object, including the one-time `secret`. A new endpoint starts unverified (`verified_at` is `null`) and only receives the live event stream once it passes verification.

```json
{
  "id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
  "organization_id": "11111111-2222-3333-4444-555555555555",
  "url": "https://hooks.example.com/ayinbox",
  "description": "Production event sink",
  "event_types": ["campaign.reply_received", "meeting.booked"],
  "enabled": true,
  "verified_at": null,
  "ownership_confirmed": false,
  "last_success_at": null,
  "last_failure_at": null,
  "last_failure_reason": null,
  "consecutive_failures": 0,
  "created_at": "2026-06-11T18:00:00Z",
  "updated_at": "2026-06-11T18:00:00Z",
  "secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
}
```

Update a webhook endpoint [#update-a-webhook-endpoint]

`PATCH /webhooks/:id`

Updates a subscription's URL, description, event filter, or enabled state. The signing secret is not changed here. Use the rotate endpoint for that. The same URL and event-type validation as create applies. Changing the URL host re-arms endpoint verification.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter | In   | Type | Description                |
| --------- | ---- | ---- | -------------------------- |
| `id`      | path | UUID | The endpoint id to update. |

Request body [#request-body-1]

Same shape as create. All fields are replaced with the values sent, so send the complete desired state (`event_types` is overwritten, not merged).

| Field         | Type      | Required | Description                                                    |
| ------------- | --------- | -------- | -------------------------------------------------------------- |
| `url`         | string    | Yes      | HTTPS, publicly routable URL.                                  |
| `description` | string    | No       | Free-text label.                                               |
| `event_types` | string\[] | No       | Replacement event filter. Empty means all non-firehose events. |
| `enabled`     | boolean   | No       | Active state. Defaults to `true` if omitted.                   |

Response [#response-3]

`200 OK` with the updated endpoint (no secret).

Delete a webhook endpoint [#delete-a-webhook-endpoint]

`DELETE /webhooks/:id`

Deletes a subscription and cascades to its delivery history.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter | In   | Type | Description                |
| --------- | ---- | ---- | -------------------------- |
| `id`      | path | UUID | The endpoint id to delete. |

Response [#response-4]

`204 No Content` with an empty body.

Rotate the signing secret [#rotate-the-signing-secret]

`POST /webhooks/:id/rotate-secret`

Issues a new signing secret and returns it once. In-flight deliveries that were already signed continue to verify against the old secret until they settle, while new deliveries use the new secret. Update your verifier promptly after rotating.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter | In   | Type | Description                |
| --------- | ---- | ---- | -------------------------- |
| `id`      | path | UUID | The endpoint id to rotate. |

Response [#response-5]

`200 OK` with the new secret. This is the only time it is returned.

```json
{
  "secret": "whsec_1a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7081"
}
```

Endpoint verification [#endpoint-verification]

`POST /webhooks/:id/verify`

New endpoints start **unverified** and do not receive real events until they pass a reachability check. Calling this endpoint sends a single signed challenge request to the target URL. It is also useful as a "send a test event" button after the endpoint is already verified.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter | In   | Type | Description                |
| --------- | ---- | ---- | -------------------------- |
| `id`      | path | UUID | The endpoint id to verify. |

Challenge request [#challenge-request]

AY Inbox sends a `POST` to the endpoint URL with `X-Ayinbox-Event: webhook.test` and a body of:

```json
{
  "event_type": "webhook.test",
  "data": {
    "challenge": "whcg_...",
    "endpoint_id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
    "message": "This is an AY Inbox webhook verification request."
  }
}
```

The challenge is signed with the endpoint's secret, so you can verify it exactly like any other delivery (see [Verifying signatures](#verifying-signatures)).

Responding to the challenge [#responding-to-the-challenge]

For simple reachability (dashboard-registered endpoints): respond with any `2xx`. The endpoint is marked verified.

For URL ownership proof (required for OAuth-app-registered endpoints): additionally echo the `challenge` value back. You may return it in the JSON response body:

```json
{ "challenge": "whcg_..." }
```

Or in a response header:

```
X-Ayinbox-Webhook-Challenge: whcg_...
```

Changing an endpoint's URL host re-arms verification: the endpoint reverts to unverified and must pass the challenge again before it resumes receiving events.

List all deliveries (org-wide) [#list-all-deliveries-org-wide]

`GET /webhooks/deliveries`

Returns delivery attempts across all endpoints for the organization, newest first. Accepts query parameters to narrow the result.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter    | In    | Type    | Description                                                                                   |
| ------------ | ----- | ------- | --------------------------------------------------------------------------------------------- |
| `status`     | query | string  | Filter by delivery status. One of `pending`, `in_flight`, `delivered`, `failed`, `abandoned`. |
| `event_type` | query | string  | Filter by event type, for example `campaign.reply_received`.                                  |
| `limit`      | query | integer | Max rows. Between 1 and 200, defaults to 50. Out-of-range values return `400`.                |
| `cursor`     | query | string  | Opaque pagination cursor from the previous response's `pagination.next_cursor`.               |

Response [#response-6]

Uses the standard `data` + `pagination` envelope with an opaque cursor.

```json
{
  "data": [
    {
      "id": "d1e2f3a4-b5c6-7d8e-9f0a-1b2c3d4e5f6a",
      "endpoint_id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
      "organization_id": "11111111-2222-3333-4444-555555555555",
      "event_type": "campaign.reply_received",
      "event_id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b",
      "status": "delivered",
      "attempt_count": 1,
      "max_attempts": 8,
      "next_attempt_at": null,
      "last_attempt_at": "2026-06-11T18:02:14Z",
      "response_status": 200,
      "response_body_excerpt": "ok",
      "error_reason": null,
      "created_at": "2026-06-11T18:02:13Z",
      "updated_at": "2026-06-11T18:02:14Z"
    }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6ImQx...",
    "has_more": true
  }
}
```

List deliveries for one endpoint [#list-deliveries-for-one-endpoint]

`GET /webhooks/:id/deliveries`

Returns recent delivery attempts for a specific endpoint, newest first. Each row reflects the latest state across all retry attempts for that event.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter    | In    | Type    | Description                                                                    |
| ------------ | ----- | ------- | ------------------------------------------------------------------------------ |
| `id`         | path  | UUID    | The endpoint id whose deliveries to list.                                      |
| `status`     | query | string  | Filter by delivery status.                                                     |
| `event_type` | query | string  | Filter by event type.                                                          |
| `limit`      | query | integer | Max rows. Between 1 and 200, defaults to 50. Out-of-range values return `400`. |
| `cursor`     | query | string  | Opaque pagination cursor.                                                      |

Response [#response-7]

Uses the same `data` + `pagination` envelope as the org-wide list.

Delivery `status` is one of `pending`, `in_flight`, `delivered`, `failed`, or `abandoned` (retries exhausted). `response_body_excerpt` is capped at the first 1024 bytes of the subscriber's response.

Redeliver a delivery [#redeliver-a-delivery]

`POST /webhooks/deliveries/:deliveryId/redeliver`

Manually re-enqueues a delivery regardless of its current status. Useful for replaying a failed or abandoned delivery after you have fixed the receiver. The redelivery is a new attempt with its own retry lifecycle, but it carries the same `event_id` so your idempotency key logic will dedupe it correctly.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

| Parameter    | In   | Type | Description                   |
| ------------ | ---- | ---- | ----------------------------- |
| `deliveryId` | path | UUID | The delivery id to redeliver. |

Response [#response-8]

`200 OK` with the updated delivery object.

List throttle drops [#list-throttle-drops]

`GET /webhooks/throttle-drops`

Returns events that were suppressed by the per-endpoint delivery rate limit or the per-org per-event-type dispatch throttle. Throttled events are surfaced here rather than silently discarded, so you can monitor for integration pressure and redeliver if needed.

Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`.

Delivery payload [#delivery-payload]

Every callback is an HTTP `POST` with a `Content-Type: application/json` body in this shape:

| Field             | Type   | Description                                                                             |
| ----------------- | ------ | --------------------------------------------------------------------------------------- |
| `id`              | UUID   | Unique event id. Stable across retries of the same event. Matches `X-Ayinbox-Event-Id`. |
| `event_type`      | string | The event name, for example `campaign.reply_received`.                                  |
| `organization_id` | UUID   | The organization the event belongs to.                                                  |
| `created_at`      | string | RFC 3339 UTC timestamp of when the event was dispatched.                                |
| `data`            | object | Event-specific payload. Shape depends on `event_type`.                                  |

```json
{
  "id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b",
  "event_type": "campaign.reply_received",
  "organization_id": "11111111-2222-3333-4444-555555555555",
  "created_at": "2026-06-11T18:02:13Z",
  "data": { "...": "event-specific object" }
}
```

There is no `version` field in the payload. If an event's payload ever changes in a backward-incompatible way, it is published under a new `event_type` name. Existing event type names are never re-versioned in place.

Reply received payload [#reply-received-payload]

`campaign.reply_received` carries `contact_email`, `intent` and `confidence` (the reply classifier's verdict, the same one the campaign's reply counts show; the workspace keyword lists decide only a reply the classifier could not), `subject`, `snippet`, `action_taken`, `thread_id` and `email_account_id`. `contact_id` and `campaign_id` are optional, included only when the reply is linked to the corresponding contact or campaign. When [inbox tagging](/guides/inbox-tagging/) judged the message it also carries the stored verdict:

```json
{
  "ai_labels": ["Meeting", "Interested"],
  "ai_intent": "scheduling",
  "ai_intent_confidence": 0.91,
  "ai_kind": "human_reply",
  "ai_relevance": 82,
  "ai_needs_review": false
}
```

`ai_kind` and `ai_intent` are empty strings when tagging was not confident enough to act on them, and the six keys are absent when tagging never judged the message. See [Automations](/guides/automations/#filtering-replies-on-inbox-tagging) for what each holds.

`inbox.reply_received` carries the same fields with `trigger` set to `inbox_reply`. It fires once per message, when a person (not an out-of-office or other automatic reply) answers an email the workspace sent through AY Inbox: a campaign step, or a message written in the inbox. The reply's `In-Reply-To` has to name that email, so a reply to mail sent from your own mail client, warmup mail and a fresh message from a contact do not raise it. Like `campaign.reply_received`, it fires only while reply handling is on in the workspace's outreach settings (the default).

Campaign completed payload [#campaign-completed-payload]

`campaign.completed` carries `campaign_id`, `campaign_name` and `reason`: `all_sent` when every lead finished the sequence, `end_date` when the campaign reached its end date. A campaign paused because verification refused its remaining leads, one waiting on held leads, and a continuous campaign out of leads are not complete and do not raise it.

Mailbox payloads [#mailbox-payloads]

Every `email_account.*` event carries `email_account_id`, `email` and `provider`.

* `email_account.disconnected` fires once when a working mailbox is paused because it can no longer be used, and adds `reason` (`auth_error` when the provider refused the sign-in, `account_disabled` when the provider disabled the account, `fair_use` when sync fair use stopped it) and `error_code`. Reconnecting the mailbox and losing it again raises it again.
* `email_account.error` fires when a new temporary sending or sync error is recorded for a mailbox, and adds `error_code` and `operation` (`send`, `sync` or `unknown`). A repeat of an error that is still unresolved, and a brief failure on a mailbox that worked moments ago, do not raise it.
* `email_account.synced` fires when a mailbox finishes importing its existing mail after it connects, and adds `backfill_status` (`complete`). It does not fire for the ongoing sync that follows.

Automation run payload [#automation-run-payload]

`automation.run` carries `automation_id`, `automation_name`, `run_id`, `trigger_event` (the event that started the run, or `campaign.action` for a run a campaign step launched) and `status` (`success`, or `error` when a step failed with no error path). It also carries `contact_id`, `contact_email` and `campaign_id` when the triggering event had them. It is a firehose event. It never starts an automation, so a flow cannot trigger itself.

Bulk operation payloads [#bulk-operation-payloads]

The `bulk_operation.*` events report contact file imports. Each carries `operation_id` (the import's id), `operation_type` (`contact_import`), `entity_type` (`contact`) and `filename`.

* `bulk_operation.started` fires when an import is started.
* `bulk_operation.completed` fires when it finishes, and adds `total`, `imported`, `updated`, `skipped` and `failed` row counts.
* `bulk_operation.failed` fires when it stops with an error, and adds `error`.

A cancelled import raises neither `completed` nor `failed`.

Delivery headers [#delivery-headers]

Each delivery carries these request headers:

| Header                | Description                                                                                            |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| `X-Ayinbox-Signature` | HMAC-SHA256 signature in the form `t=<unix>,v1=<hex>`.                                                 |
| `X-Ayinbox-Event`     | The event type, so you can route without parsing the body.                                             |
| `X-Ayinbox-Event-Id`  | The unique event id (same as the payload `id`). Stable across retries. Use it as your idempotency key. |
| `User-Agent`          | `Ayinbox-Webhooks/1.0`.                                                                                |

Verifying signatures [#verifying-signatures]

AY Inbox signs every delivery with HMAC-SHA256 using the endpoint's signing secret. The `X-Ayinbox-Signature` header has the form `t=<unix-timestamp>,v1=<hex-digest>`. The signed string is the timestamp, a literal `.`, then the exact raw request body.

To verify:

1. Parse `t` and `v1` from the header.
2. Reject the request if `t` is more than 5 minutes from the current wall clock. Never use a tolerance of 0 (that would break deliveries that arrive even one second late).
3. Compute `HMAC-SHA256(secret, t + "." + rawRequestBody)` and hex-encode the result.
4. Compare your digest against `v1` using a **constant-time** comparison to avoid timing attacks.
5. Ignore any scheme that is not `v1` (downgrade defense: future schemes may exist).

Always compute the HMAC over the **raw, unmodified request body** before any JSON re-serialization. The secret is the `whsec_`-prefixed value returned at create or rotate time.

```text
# Pseudocode
signed_payload = t + "." + raw_body
expected_v1    = hex(hmac_sha256(whsec_..., signed_payload))
is_valid       = constant_time_equal(expected_v1, v1) AND abs(now() - t) < 300
```

Example in Python:

```python
import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    if abs(time.time() - int(t)) > 300:
        return False  # replay too old
    key = secret.encode()  # whsec_... as bytes
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

Use `X-Ayinbox-Event-Id` (or the payload `id` field) as an idempotency key to dedupe replays. The same event id can arrive more than once if a retry fires before AY Inbox receives your `2xx`.

Retries and backoff [#retries-and-backoff]

Deliveries are enqueued and drained asynchronously, so the dispatching API call returns immediately and is not blocked on your endpoint.

* A `2xx` response marks the delivery `delivered` and resets the endpoint's failure health.
* Any non-`2xx` response (including `3xx`: redirects are never followed), a connection error, or a timeout (15-second request timeout) marks the attempt failed and schedules a retry.
* Backoff doubles each retry starting at 30 seconds: 30s, 1m, 2m, 4m, 8m, 16m, 32m. Subsequent waits are capped at 1 hour.
* The default `max_attempts` is 8. Once attempts are exhausted the delivery is marked `abandoned`.
* Both `4xx` and `5xx` responses are retried up to the attempt cap. A `4xx` may be a transient validation flap.
* Endpoints that fail continuously over a sustained window are auto-disabled to protect third parties. Re-enable them in Settings once your receiver is stable.

Make your handler idempotent. Dedupe on `X-Ayinbox-Event-Id` since the same event id can arrive more than once across retries.

Firehose events [#firehose-events]

High-volume events (marked `firehose: true` in the event catalog) are **opt-in only**. An endpoint whose `event_types` list is empty or omitted subscribes to all standard events but will not receive firehose events. To receive a firehose event, list it explicitly:

```json
{
  "event_types": [
    "campaign.reply_received",
    "campaign.email_sent",
    "campaign.email_opened"
  ]
}
```

This keeps default integrations from being overwhelmed by send-pipeline or inbox-sync volume. Subscribe selectively and ensure your receiver can handle the sustained throughput before opting in.

Security [#security]

* **HTTPS only**: endpoint URLs must use `https://`. Plain HTTP is rejected at registration time.
* **SSRF protection**: outbound delivery is hardened against SSRF. Private, loopback, link-local, and cloud-metadata IP ranges are blocked at dial time. Redirects are never followed. Only standard web ports are allowed.
* **Rate limits**: a per-endpoint delivery rate limit and a per-org per-event-type dispatch throttle protect both your receivers and third parties. Throttled events appear in `GET /webhooks/throttle-drops`.
* **Signature verification**: always verify the `X-Ayinbox-Signature` header. Ignoring it means any party that knows your endpoint URL can replay or spoof events.

Event catalog [#event-catalog]

The full catalog is available at `GET /webhooks/event-types`. Below is the complete reference grouped by category. Events marked **firehose** must be subscribed to explicitly (see [Firehose events](#firehose-events)).

Campaign [#campaign]

| Event                             | Description                                                                                                                                                                                                                                                                                                                                  | Firehose |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `campaign.started`                | A campaign starts sending.                                                                                                                                                                                                                                                                                                                   |          |
| `campaign.paused`                 | A campaign is paused.                                                                                                                                                                                                                                                                                                                        |          |
| `campaign.completed`              | A campaign finishes: every lead is done, or it reached its end date. See [Campaign completed payload](#campaign-completed-payload).                                                                                                                                                                                                          |          |
| `campaign.created`                | A campaign is created.                                                                                                                                                                                                                                                                                                                       |          |
| `campaign.updated`                | A campaign's settings change.                                                                                                                                                                                                                                                                                                                |          |
| `campaign.deleted`                | A campaign is deleted.                                                                                                                                                                                                                                                                                                                       |          |
| `template.created`                | An email template is created.                                                                                                                                                                                                                                                                                                                |          |
| `template.updated`                | An email template changes.                                                                                                                                                                                                                                                                                                                   |          |
| `template.deleted`                | An email template is deleted.                                                                                                                                                                                                                                                                                                                |          |
| `campaign.email_sent`             | A campaign email is dispatched by a worker.                                                                                                                                                                                                                                                                                                  | yes      |
| `campaign.email_delivered`        | **Deprecated, never delivered.** AY Inbox cannot observe delivery. Use `campaign.email_sent` and `campaign.email_bounced`.                                                                                                                                                                                                                   | yes      |
| `campaign.email_opened`           | A person opens a tracked email. The payload carries `origin` when the open said anything about itself: the mail client, app or webmail, device and location, as on a [contact timeline](/api/reference/contacts/#list-a-contacts-timeline) event. Automated opens (mail privacy proxies, fetches inside seconds of the send) do not fire it. | yes      |
| `campaign.email_clicked`          | A person clicks a tracked link. The payload carries the `url`, the link's `link_label`, and `origin` with the device and browser the link opened in. Automated clicks (security gateways walking the links) do not fire it.                                                                                                                  | yes      |
| `campaign.email_bounced`          | A campaign email bounces.                                                                                                                                                                                                                                                                                                                    |          |
| `campaign.reply_received`         | A human reply lands for a campaign thread.                                                                                                                                                                                                                                                                                                   |          |
| `campaign.unsubscribed`           | A recipient opts out. `source` is `one_click` (the mail client's button), `link` (the link in the email), `reply` (a reply asking to stop) or `action` (a sequence's unsubscribe step).                                                                                                                                                      |          |
| `campaign.deliverability_warning` | The campaign's rolling bounce or complaint rate enters the early-warning band.                                                                                                                                                                                                                                                               |          |
| `campaign.action`                 | **Deprecated, never delivered.** It is only the trigger of automations a campaign step runs.                                                                                                                                                                                                                                                 |          |

Warmup [#warmup]

| Event                      | Description                                                                                                                                                                                                                                                                                                               | Firehose |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `warmup.email_sent`        | A warmup message is sent.                                                                                                                                                                                                                                                                                                 | yes      |
| `warmup.health_changed`    | A mailbox's warmup health state transitions.                                                                                                                                                                                                                                                                              |          |
| `warmup.placement_in_spam` | A warmup message is observed landing in the spam folder. The payload carries the sending mailbox (`email_account_id`, `email`), `content_source`, and the recipient's `recipient_provider` (how it connects) and `recipient_host` (who runs its mail, such as `google_workspace` or `zoho`), never its address or domain. |          |
| `warmup.quarantined`       | A mailbox is quarantined from the shared warmup pool.                                                                                                                                                                                                                                                                     |          |
| `warmup.blocked`           | A mailbox is blocked from the shared warmup pool.                                                                                                                                                                                                                                                                         |          |

Deliverability [#deliverability]

| Event                      | Description                                  | Firehose |
| -------------------------- | -------------------------------------------- | -------- |
| `deliverability.bounce`    | A bounce signal is processed (hard or soft). |          |
| `deliverability.complaint` | A complaint (spam report) is processed.      |          |

Inbox [#inbox]

| Event                  | Description                                                                                                                                        | Firehose |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `inbox.email_received` | A new email arrives in a connected mailbox.                                                                                                        | yes      |
| `inbox.email_updated`  | **Deprecated, never delivered.** Per-message inbox changes are not sent as webhooks.                                                               | yes      |
| `inbox.email_deleted`  | **Deprecated, never delivered.** Per-message inbox changes are not sent as webhooks.                                                               | yes      |
| `inbox.reply_received` | A person (not an automatic reply) answers a campaign email or an email sent from the inbox. See [Reply received payload](#reply-received-payload). |          |

Mailbox [#mailbox]

| Event                          | Description                                                                                                                                                 | Firehose |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `email_account.connected`      | A mailbox is connected to the organization.                                                                                                                 |          |
| `email_account.disconnected`   | A working mailbox is paused because it can no longer be used (sign-in refused, account disabled, fair-use stop). See [Mailbox payloads](#mailbox-payloads). |          |
| `email_account.removed`        | A mailbox is removed from the organization.                                                                                                                 |          |
| `email_account.error`          | A mailbox hits a new temporary sending or sync error.                                                                                                       |          |
| `email_account.synced`         | A mailbox finishes importing its existing mail after it connects.                                                                                           |          |
| `email_account.health_changed` | **Deprecated, never delivered.** Use `warmup.health_changed`, which carries the same transition.                                                            |          |

Contact [#contact]

| Event             | Description                                                                                                                                                                                                                                                                                                                                                                                                                      | Firehose |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `contact.created` | A contact is created. Carries `contact_id`, `contact_email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `custom_fields`, `source`, `source_detail`, `campaign_ids`, `category_ids` and `created_at`. Fires for contacts added by hand, through the API, by a form or by an automation; file imports, sheet syncs and a single API request adding more than 100 contacts are bulk arrivals and do not raise it. |          |
| `contact.updated` | A contact's fields change.                                                                                                                                                                                                                                                                                                                                                                                                       |          |
| `contact.deleted` | A contact is deleted.                                                                                                                                                                                                                                                                                                                                                                                                            |          |
| `form.submitted`  | A hosted form received a submission. Carries the form id and name, the submission id, the answers under `data`, the mapped contact columns (`contact_email`, `first_name`, `last_name`, `company`, `phone`), `source_url`, and the contact id when one was created or matched.                                                                                                                                                   |          |

Auto-reply [#auto-reply]

| Event                   | Description                                                                                                                                                                    | Firehose |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| `auto_reply.sent`       | A campaign on [auto-reply](/guides/auto-reply/) answered a reply. Carries `run_id`, `campaign_id`, `contact_id`, `thread_id`, `status`, `reason` and `intent`, never the body. |          |
| `auto_reply.handed_off` | A reply a campaign would answer automatically was handed to a person, or an automatic answer failed or may not have left. Same fields; `reason` is the stable reason code.     |          |

Signals [#signals]

| Event             | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Firehose |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `signal.detected` | A watched company has a new [signal](/guides/signals/). One event per signal, the first time it is found. Carries `signal_id`, `type` (`hiring`, `funding`, `news` or `leadership`), `domain`, `company_name`, `headline`, `detail`, `evidence_url`, `occurred_at` when the source dates it, `first_seen_at` and `watched_company_id`. The headline, detail and link come from outside providers, so treat them as untrusted text. An app subscription needs `READ_CONTACTS`. |          |

CRM [#crm]

| Event                  | Description                                                                                                                                                                                                                                                                                                                                  | Firehose |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `crm.deal_created`     | A deal is created.                                                                                                                                                                                                                                                                                                                           |          |
| `crm.deal_updated`     | A deal's fields or stage change.                                                                                                                                                                                                                                                                                                             |          |
| `crm.deal_deleted`     | A deal is deleted.                                                                                                                                                                                                                                                                                                                           |          |
| `crm.task_created`     | A CRM task is created.                                                                                                                                                                                                                                                                                                                       |          |
| `crm.task_updated`     | A CRM task changes. A bulk write ([bulk update tasks](/api/reference/crm/#bulk-update-tasks)) raises it once for the whole call, with no `entity_id` and a `metadata` of `bulk` and `count`, so treat a missing id as "re-read the tasks you track" rather than as one task. `contact.updated` behaves the same way for a bulk contact edit. |          |
| `crm.note_created`     | A note is added to a contact or deal.                                                                                                                                                                                                                                                                                                        |          |
| `crm.pipeline_updated` | A pipeline or its stages change.                                                                                                                                                                                                                                                                                                             |          |

Automation [#automation]

| Event                | Description                                                                          | Firehose |
| -------------------- | ------------------------------------------------------------------------------------ | -------- |
| `automation.created` | An automation is created.                                                            |          |
| `automation.updated` | An automation's definition or enabled state changes.                                 |          |
| `automation.deleted` | An automation is deleted.                                                            |          |
| `automation.run`     | An automation finishes a run. See [Automation run payload](#automation-run-payload). | yes      |

Meeting [#meeting]

| Event                 | Description                                                           | Firehose |
| --------------------- | --------------------------------------------------------------------- | -------- |
| `meeting.booked`      | A call is booked through a connected scheduler (Calendly or Cal.com). |          |
| `meeting.rescheduled` | A booked call is rescheduled.                                         |          |
| `meeting.canceled`    | A booked call is canceled.                                            |          |

Every `meeting.*` payload carries `booking_id`, `source`, `status`, the invitee, `event_name`, `scheduled_for`, the join, cancel and reschedule links, `contact_id`, and three keys a receiver can rely on to tell meetings apart: `external_event_id` (the provider's id for the meeting), `campaign_id` (set when the meeting came from a campaign reply) and `booked_via` (`agent`, `member`, `api` or `link`). Each meeting state is announced once; a provider redelivering the same state sends nothing new.

Team and access [#team-and-access]

| Event                 | Description                                | Firehose |
| --------------------- | ------------------------------------------ | -------- |
| `team.member_invited` | A team member invitation is sent.          |          |
| `team.member_removed` | A member is removed from the organization. |          |
| `role.created`        | A custom role is created.                  |          |
| `role.updated`        | A custom role's permissions change.        |          |
| `role.deleted`        | A custom role is deleted.                  |          |

Bulk operations [#bulk-operations]

| Event                      | Description                                                                                | Firehose |
| -------------------------- | ------------------------------------------------------------------------------------------ | -------- |
| `bulk_operation.started`   | A contact file import is started. See [Bulk operation payloads](#bulk-operation-payloads). |          |
| `bulk_operation.completed` | A contact file import finishes.                                                            |          |
| `bulk_operation.failed`    | A contact file import stops with an error.                                                 |          |

Workspace [#workspace]

| Event                      | Description                                                | Firehose |
| -------------------------- | ---------------------------------------------------------- | -------- |
| `lead_sync_source.updated` | A lead sync source (CSV import, Google Sheets) is updated. |          |
| `settings.updated`         | Organization settings change.                              |          |
| `subscription.updated`     | The billing subscription changes (plan, seats, status).    |          |

Developer [#developer]

| Event          | Description                                                                   | Firehose |
| -------------- | ----------------------------------------------------------------------------- | -------- |
| `custom.event` | A custom event emitted by an automation or campaign sequence Fire Event step. |          |

Trigger-only (not delivered outbound) [#trigger-only-not-delivered-outbound]

`inbound.webhook` is an automation trigger for receiving inbound HTTP requests. It is never delivered outbound as a webhook event.

Deprecated events [#deprecated-events]

These events are in the catalog with `deprecated: true` and are never delivered. They remain valid in `event_types` and as automation triggers, so an endpoint or automation that already names one keeps saving, but it receives nothing for it. The dashboard's event pickers no longer offer them.

| Event                          | Why                                                                                                       | Use instead                                                        |
| ------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `campaign.email_delivered`     | AY Inbox hands mail to the sender's own mailbox provider and cannot see the recipient's server accept it. | `campaign.email_sent`, then `campaign.email_bounced` if it bounces |
| `campaign.action`              | It names the trigger of an automation a campaign step runs, not something that happens on its own.        | `automation.run`, or a Fire event step's `custom.event`            |
| `inbox.email_updated`          | Per-message changes (read state, labels, moves) have no single source that reports each one once.         | `inbox.email_received`, `inbox.reply_received`                     |
| `inbox.email_deleted`          | As above.                                                                                                 | `inbox.email_received`, `inbox.reply_received`                     |
| `email_account.health_changed` | The mailbox health shown in the dashboard is its warmup health, which already has an event.               | `warmup.health_changed`                                            |

OAuth app-level subscriptions [#oauth-app-level-subscriptions]

OAuth apps can receive events without registering per-org endpoints manually. Set `webhook_url` and `webhook_events` on the app once, and AY Inbox automatically materializes a managed endpoint for every org that authorizes the app. Events are gated by the intersection of the app's declared events and the org's granted scopes. The app signs all deliveries with a single shared `whsec_` secret, verifiable with the same scheme documented in [Verifying signatures](#verifying-signatures).

Four management endpoints let you inspect and maintain the app-level delivery pipeline: reveal the secret, rotate it, list per-org endpoints with health, and page through the cross-org delivery log. All four require **Scope** `API_KEYS` and **org permission** `manage_api_keys`. See [OAuth: receiving events as an app](/api/oauth/#receiving-events-as-an-app-webhooks) for the full reference, including the scope-to-event gating table and a comparison with the realtime gateway.

OAuth app webhook domains [#oauth-app-webhook-domains]

OAuth applications that register webhook endpoints on behalf of their users must declare the domains those endpoints may use. The field is `allowed_webhook_domains` (a string array) on the OAuth app object.

Any webhook endpoint registered by an OAuth app must have its URL host within the app's `allowed_webhook_domains` list. This is enforced at registration time and re-checked at delivery time. An app with an empty `allowed_webhook_domains` list cannot register any webhook endpoints.

**Matching rules:**

* A bare entry (`acme.com`) matches that exact host only.
* A leading-dot entry (`.acme.com`) matches the apex domain and any subdomain: `acme.com`, `hooks.acme.com`, `prod.hooks.acme.com`.

Example: if `allowed_webhook_domains` is `[".acme.com"]`, then `https://hooks.acme.com/ayinbox` is allowed and `https://otherdomain.com/ayinbox` is rejected.

Set this field when creating or updating your OAuth app:

```json
{
  "name": "My App",
  "allowed_webhook_domains": [".acme.com"]
}
```

See [OAuth](/api/oauth/) for the full app registration flow and field reference.

Pushing custom data from a flow [#pushing-custom-data-from-a-flow]

To push custom data from a campaign or automation flow into your endpoints, add a "Fire event" action step: it emits a `custom.event` that every endpoint subscribed to it receives, signed with that endpoint's own secret and retried like any other delivery. That keeps a single, consistent verification model: one secret per endpoint, with signed and retried delivery.

Errors [#errors]

Webhook endpoints use the standard error envelope with stable `code` and `request_id` fields (see [error codes](/api/error-codes/)). Common cases:

* `400` invalid payload, a non-HTTPS or non-routable `url`, an unknown event type, or a `limit` outside 1 to 200.
* `400` an invalid endpoint id or delivery id in the path.
* `403` an OAuth app attempting to register an endpoint whose URL host is not in `allowed_webhook_domains`.
* `404` the endpoint or delivery does not exist or does not belong to your organization.

```json
{
  "error": "Bad Request",
  "message": "unknown event type: campaign.exploded",
  "code": "bad_request",
  "request_id": "req_7Yc2pK1nQ4"
}
```
