# Events catalog

Every event opendesk emits, its visibility and its data.

Every state change that someone else should see is written as an event, in the same database transaction as the change. Events are delivered three ways: on the [realtime gateway](/api/realtime/), to [webhooks](/api/webhooks/), and through replay with `GET /v1/events?since=<seq>`.

## How events flow

*Diagram: How a change reaches everyone. A request writes the change and an event row in one Postgres transaction. The relay, on the worker role, reads events in seq order and publishes each to NATS on od.events.<wid>; it also queues signed webhook deliveries. The realtime gateway pushes each event to its topics: workspace:<wid> gets every event, or only a stand-in for an event about a private team's ticket; team:<wid>:<tid> gets that team's events in full; user and contact topics get notifications and the chat widget's events. Browsers apply events, track seq on the workspace channel and fetch any gap from GET /v1/events.*

*One write, one event, delivered live and to webhooks. A private team's events go only to that team.*

1. A request writes its change and the event in **one transaction**, so an event exists exactly when its change does.
2. The **relay** reads new events in `seq` order and publishes each to NATS on `od.events.<workspace_id>`. It also queues a signed delivery for each matching [webhook](/api/webhooks/).
3. The **realtime gateway** pushes each event to the topics it names. Most events go to `workspace:<wid>`, which everyone in the workspace joins.
4. An event about a ticket in a **private team** goes in full to `team:<wid>:<tid>`, which only people who can see that team's tickets can join. `workspace:<wid>` gets a stand-in with the same `seq` and no data (`type: "event.hidden"`), so `seq` stays contiguous for everyone. Replay answers the same stand-in for events you may not see, and a webhook receives them only when it was granted that team.

## Envelope

```jsonc
{
  "id": "0192f0c4-...",          // UUID v7, unique
  "seq": 1042,                    // contiguous per workspace
  "workspace_id": "…",
  "type": "message.created",
  "visibility": "agents",         // "agents" | "public"
  "topics": ["workspace:<wid>", "user:<uid>", "contact:<wid>:<contact_id>"],
  "data": { },
  "occurred_at": "2026-10-11T09:00:00Z"
}
```

- `seq` increases by exactly one per event within a workspace. A jump means you missed events.
- `visibility: "public"` events may reach the end customer in the chat widget. Everything else is `"agents"`: internal notes, assignment, tags, SLA and all settings.
- `topics` decide realtime fan-out. Webhooks receive the envelope without `topics`.
- Objects in `data` are the same JSON the REST API returns.

## Catalog

| Type | Visibility | Extra topics | Data |
| --- | --- | --- | --- |
| `ticket.created` | agents | | `{ ticket }` |
| `ticket.updated` | agents | `user:<assignee>` when the assignee changes | `{ ticket, changes: ["status", "assignee_id", "fields", ...] }` |
| `ticket.deleted` | agents | | `{ ticket_id }` |
| `message.created` | see below | `contact:<wid>:<cid>` when public | `{ ticket_id, message }` |
| `message.updated` | agents | | `{ ticket_id, message }`, for delivery status changes |
| `activity.created` | agents | | `{ ticket_id, activity }` |
| `contact.created`, `contact.updated` | agents | | `{ contact }` |
| `contact.deleted` | agents | | `{ contact_id }` |
| `company.created`, `company.updated` | agents | | `{ company }` |
| `company.deleted` | agents | | `{ company_id }` |
| `tag.created`, `tag.updated` | agents | | `{ tag }` |
| `tag.deleted` | agents | | `{ tag_id }` |
| `tag.merged` | agents | | `{ tag_id, into }` (records that carried it change without their own events; refetch lists) |
| `tag_group.created`, `tag_group.updated` | agents | | `{ tag_group }` |
| `tag_group.deleted` | agents | | `{ tag_group_id }` |
| `custom_field.created`, `custom_field.updated` | agents | | `{ custom_field }` |
| `custom_field.deleted` | agents | | `{ custom_field_id, entity, key }` |
| `mailbox.created`, `mailbox.updated` | agents | | `{ mailbox }` |
| `mailbox.deleted` | agents | | `{ mailbox_id }` |
| `member.added`, `member.updated` | agents | `user:<uid>` | `{ member }`, also on a picture, availability or cap change |
| `member.removed` | agents | `user:<uid>` | `{ user_id }` |
| `workspace.updated` | agents | | `{ workspace }`, when the name, settings, logo or a brand image changes |
| `notification.created` | agents | `user:<uid>` | `{ notification }` |
| `saved_reply.*`, `view.*`, `automation.*`, `sla_policy.*`, `kb_article.*`, `kb_collection.*`, `team.*`, `webhook.*` | agents | | `{ <resource> }` on create and update, `{ <resource>_id }` on delete |
| `billing.updated` | agents | | `Billing`, see [Billing API](/api/resources/billing/). Cloud only. |
| `webhook.test` | | | sent only to the webhook being tested |

### message.created visibility

| Message | Visibility |
| --- | --- |
| A note | `agents` |
| A reply on a chat ticket | `public`, also sent to the contact's topic |
| A reply on an email ticket | `agents` (the customer receives it by email) |

### Gateway-only events

`chat.typing` is not stored, has no `seq` and never reaches webhooks. The gateway sends typing indicators directly to the ticket and contact topics. See [Realtime](/api/realtime/#presence-and-typing-on-a-ticket).

AI summaries and triage results arrive as `ticket.updated` with `custom` in `changes` (Ollie's decisions in `custom.ai_decisions`, pending suggestions in `custom.ai_suggestions`); fields triage applied are listed too, such as `priority`, `severity` and `tags`. Chat widget AI answers arrive as a normal `message.created`.

## Retention

Events are kept for 7 days (`EVENT_RETENTION` on self-hosted instances) for replay, then pruned.
