# Jelly API reference

> The machine-readable form of this document is `openapi.json`, beside it.

The Jelly API lets a program read and change a team's email over HTTP.
Every endpoint is under `/api`. This reference is generated from the
code, so it lists every endpoint. It is also available as
[OpenAPI 3.1](/api-reference/openapi.json) and as
[markdown](/api-reference/reference.md).

How tokens work, and what a token can see and change, is in the manual:
[The API](/manual/advanced/api).

## Tokens

Send a token in the `Authorization` header:

```
Authorization: Bearer jelly_…
```

There are two kinds of token:

- **Team token** (`jelly_…`): made by an Admin on **Settings → API
  tokens**. It acts in its own name and sees only the mailboxes
  it was given. It cannot send email.
- **Personal token** (`jellyp_…`): a person's own sign-in, from an app
  that uses [OAuth](/manual/advanced/api#oauth): Jelly for iOS, or an
  app the team registered. It can do what that person can do in the web
  app, including sending, within its [scopes](#description/scopes). It
  lasts an hour.

Each endpoint shows which tokens it accepts. Endpoints about one
person's own view of the mail, and sending, need a personal token. A
team token gets `403 needs_person` there. Changing labels or the
autoresponder needs **change team settings**.

## Scopes

A personal token from an app the team registered (see
[Apps](/manual/advanced/api#apps)) carries scopes. Each endpoint shows
the scope it needs:

| Scope | What it allows |
| --- | --- |
| `read` | Every `GET`: conversations, threads, notes, contacts, people, the live stream |
| `write` | Every change that sends nothing and is not a team setting: actions, notes, drafts, fields, contacts |
| `send` | Sending a draft, and undoing a send |
| `settings` | Changing labels, the autoresponder, field definitions and contact card sources |

`GET /api/me` needs no scope. A request outside the token's scopes gets
`403 insufficient_scope`, with the scope it needed in `scope` and in
`WWW-Authenticate`:

```
WWW-Authenticate: Bearer realm="jelly", error="insufficient_scope", scope="send"
```

A scope never gives a person more than they have. A Member's token with
`settings` still gets `403 forbidden` when it changes a label, because
only an Admin may. Personal tokens from Jelly's own apps have no scopes
and can do what the person can do.

Some links need no token, because they are signed: file downloads,
pictures in messages, and avatars. These are marked **Signed link**.

## Versions

The API has dated versions. Send the version your client was written
for in the `Jelly-Version` header:

```
Jelly-Version: 2026-10-22
```

Every answer has a `Jelly-Version` header naming the version it was
answered as.

Without the header, Jelly uses the version the token is pinned to. A
team token, and an app the team registers, is pinned to the newest
version on the day it is made. Tokens made before 22 October 2026, and
Jelly's own apps, get the oldest version still supported.

A date that is not a supported version gets `400 unknown_version`, with
the oldest and newest supported dates in `oldest` and `newest`.

The supported versions are `2026-10-09` and `2026-10-22`. What changed
between them is in the [Changelog](#description/changelog).

## What we promise

- Additive changes can happen at any time: new endpoints, new fields
  in an answer, new webhook events, and new values for fields such as
  `status`. Your client must ignore fields and values it does not know.
- Fields in a request that Jelly does not know are ignored.
- A breaking change, such as removing or renaming a field, changing
  what a field means, or refusing a request that used to work, comes
  only with a new version. Your version does not change until you
  change it.
- A version is supported for 12 months after the next one is released.
  An answer on an older version has a `Deprecation` header, the date
  the next version was released, and a `Sunset` header, the date the
  older one stops being supported. After that date, a token pinned to
  it gets the oldest version still supported.
- Every change is in the [Changelog](#description/changelog), with its
  date and the endpoints it affects.

### Tiers

Each endpoint has a tier:

- **Integration**: endpoints a team token can call. The promise above
  applies in full.
- **App**: endpoints only a personal token can call. They show one
  person's view of the mail as Jelly's own apps draw it: inbox sections
  and their titles, highlighted search matches, activity lines. They
  follow the same versions, but section titles, colours and the
  wording of lines follow the apps and can change in any version.

## Identifiers

| Resource | Id |
| --- | --- |
| Conversation | its slug, such as `broken-widget-k3f9zq` |
| Message, note | its key |
| Label, contact | its slug |
| Member, mailbox, attachment, draft | a number |

A label's id stays the same when the label is renamed.

## Responses

A single resource is returned under its name, such as
`{ "conversation": {…} }`. A list is returned under its plural, with a
cursor:

```json
{ "conversations": [ … ], "next_cursor": "…" }
```

To get the next page, send `next_cursor` back as `?cursor=`. It is
`null` on the last page. Some lists accept `?limit=` from 1 to 100; the
default is 50. Short lists, such as labels and members, are returned in
full.

### People

Where a response says who did something, it is one of:

```json
{ "type": "member",    "id": 3, "name": "Sarah Chen", "email": "sarah@acme.test", "role": "admin", "active": true, "avatar": { "…": "…" } }
{ "type": "api_token", "id": 7, "name": "The night shift" }
null
```

`null` means Jelly did it, for example when a rule ran. A member who
acted through an assistant, such as Claude, or through an app the team
registered, also has `via`, naming the assistant or the app.

### Avatars

A member, contact or sender has an `avatar`:

```json
"avatar": {
  "url": "https://jelly.acme.test/api/avatars/3f9c…/download?expires=…&signature=…",
  "initials": "SC",
  "tint": {
    "light": { "background": "#5e35b1", "text": "#ffffff" },
    "dark":  { "background": "#5e35b1", "text": "#ffffff" }
  }
}
```

When `url` is `null`, show `initials` on the `tint` colour, as the web
app does. `url` is a signed link that lasts at least a day. Cache the
picture by the `{hash}` in its path, since the rest of the link changes.

### Links

Conversations, messages and notes have a `url`: their page in the web
app. Use it as given rather than building links yourself.

## Errors

An error has an `error` code and a `message` you can show to a person:

```json
{ "error": "not_found", "message": "No such resource." }
```

| Status | Meaning |
| --- | --- |
| 400 | `unknown_version`: the `Jelly-Version` header names no supported version |
| 422 | The request is not valid. `errors` lists the problems |
| 401 | No token, or the token does not work. An expired personal token also gets `WWW-Authenticate: Bearer error="invalid_token"`: refresh it |
| 402 | `paused`: the team's subscription is paused. Team tokens are refused; personal tokens can only read |
| 403 | `needs_person`: this needs a personal token. `forbidden`: this token or person may not do this. `insufficient_scope`: the token's scopes do not include `scope` |
| 404 | Not found, or not visible to this token |
| 405 | Wrong method for this path |
| 409 | Conflicts with the current state, such as a draft that changed (`stale`), or a retry while the first request is still running (`idempotency_in_progress`) |
| 410 | A signed link has expired, or `cursor_expired`: a change feed cursor is too old |
| 413 | Files over 10 MB |
| 415 | Not a picture |
| 423 | `locked`: someone else is editing this draft |
| 429 | Too many requests. `Retry-After` says how long to wait |
| 500 | Server error. `ref` identifies it in the server's log |

A `422` lists each problem in `errors`, with the field it is about:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    { "field": "until", "code": "invalid", "message": "until must be in the future" }
  ]
}
```

`field` is `null` when the problem is not about one field. `code` is
`required`, `unknown` (the value names nothing this token can use) or
`invalid`. More codes may be added. Before version `2026-10-22`, each
problem is its `message` alone, as a string.

## Rate limits

- 5,000 requests every 5 minutes from one address.
- 100,000 requests a day for the whole team, reset at midnight UTC.

Both return `429` with `Retry-After` when reached.

## Retrying safely

Send an `Idempotency-Key` header on a `POST`, `PATCH` or `DELETE` to
make it safe to send again. Use a new random value, such as a UUID, for
each new request, and the same value when you retry that request. The
key is 1 to 255 characters.

- The first request runs, and Jelly keeps its answer for 24 hours.
- A retry with the same key, method, path and body does not run again.
  It gets the first answer back, status and body, with
  `Idempotent-Replayed: true`. This is true of a refusal too: a `422`
  is sent again as a `422`.
- A server error (`500`) is not kept. A retry runs again.
- The same key with a different method, path or body is refused with
  `422` and `idempotency_key_reused`.
- A retry while the first request is still running is refused with
  `409`, `idempotency_in_progress` and `Retry-After`. Wait and send it
  again.

Keys belong to the token that sent them, and a refreshed personal token
keeps the keys of the sign-in it came from. Two tokens can use the same
key without meeting. A request without the header is not remembered.

`client_key` on `POST /api/drafts` and
`POST /api/conversations/{id}/draft_reply` still works. The header is
the general form, and works for team tokens too.

Every response says where you stand against whichever of the two is
closer to running out:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | The size of that limit: 5,000 or 100,000 |
| `X-RateLimit-Remaining` | Requests left before it is reached |
| `X-RateLimit-Reset` | When it starts again, in Unix seconds |

To keep up with changes without polling every list, use
[`GET /api/changes`](#tag/changes).

## Capabilities

`GET /api/me` lists `capabilities`, what this server can do that
another may not. Today that is `push` alone. Every other feature in this
reference is on every server.

## Every endpoint

Each endpoint, the tokens it accepts, and its tier.

| Endpoint | What it does | Accepts | Tier |
|---|---|---|---|
| `GET /api/me` | Get the signed-in person and server capabilities | Personal | App |
| `PATCH /api/me/preferences` | Update the signed-in person's preferences | Personal | App |
| `GET /api/app` | Get the installation of the app this token belongs to | Personal | App |
| `GET /api/inbox` | Get the inbox | Personal | App |
| `GET /api/lists/{list}` | Get a list | Personal | App |
| `GET /api/sidebar` | Get counts, mailboxes and labels | Personal | App |
| `GET /api/conversations` | List conversations | Team, personal | Integration |
| `GET /api/conversations/search` | Search conversations | Team, personal | Integration |
| `GET /api/conversations/{id}` | Get a conversation | Team, personal | Integration |
| `PATCH /api/conversations/{id}` | Update a conversation's fields and external id | Team, personal | Integration |
| `GET /api/conversations/{id}/thread` | Get a conversation's thread | Personal | App |
| `GET /api/conversations/{id}/markdown` | Get a conversation as markdown | Team, personal | Integration |
| `GET /api/conversations/{id}/messages` | List a conversation's messages | Team, personal | Integration |
| `POST /api/conversations/bulk` | Change many conversations at once | Personal | App |
| `POST /api/conversations/{id}/archive` | Archive | Team, personal | Integration |
| `DELETE /api/conversations/{id}/archive` | Unarchive | Team, personal | Integration |
| `POST /api/conversations/{id}/trash` | Move to the Trash | Team, personal | Integration |
| `DELETE /api/conversations/{id}/trash` | Restore from the Trash | Team, personal | Integration |
| `POST /api/conversations/{id}/spam` | Mark as spam | Team, personal | Integration |
| `DELETE /api/conversations/{id}/spam` | Mark as not spam | Team, personal | Integration |
| `POST /api/conversations/{id}/snooze` | Snooze | Team, personal | Integration |
| `DELETE /api/conversations/{id}/snooze` | End a snooze | Team, personal | Integration |
| `POST /api/conversations/{id}/labels` | Add a label | Team, personal | Integration |
| `DELETE /api/conversations/{id}/labels/{label}` | Remove a label | Team, personal | Integration |
| `POST /api/conversations/{id}/assignments` | Assign | Team, personal | Integration |
| `DELETE /api/conversations/{id}/assignments/{member}` | Unassign | Team, personal | Integration |
| `PATCH /api/conversations/{id}/mailboxes` | Set mailboxes | Team, personal | Integration |
| `POST /api/conversations/{id}/read` | Mark read | Personal | App |
| `DELETE /api/conversations/{id}/read` | Mark unread | Personal | App |
| `POST /api/conversations/{id}/bookmark` | Bookmark | Personal | App |
| `DELETE /api/conversations/{id}/bookmark` | Remove bookmark | Personal | App |
| `POST /api/conversations/{id}/follow` | Follow | Personal | App |
| `DELETE /api/conversations/{id}/follow` | Stop following | Personal | App |
| `POST /api/conversations/{id}/aside` | Set aside | Personal | App |
| `DELETE /api/conversations/{id}/aside` | Undo set aside | Personal | App |
| `GET /api/conversations/{id}/notes` | List notes | Team, personal | Integration |
| `POST /api/conversations/{id}/notes` | Add a note | Team, personal | Integration |
| `DELETE /api/conversations/{id}/notes/{note}` | Delete a note | Personal | App |
| `POST /api/conversations/{id}/notes/{note}/reactions` | Add a reaction | Personal | App |
| `PATCH /api/conversations/{id}/notes/{note}/reactions/{reaction}` | Change a reaction | Personal | App |
| `DELETE /api/conversations/{id}/notes/{note}/reactions/{reaction}` | Remove a reaction | Personal | App |
| `GET /api/reactions` | List reaction emoji | Team, personal | Integration |
| `GET /api/messages/{key}` | Get a message | Team, personal | Integration |
| `POST /api/messages` | Insert a message | Team, personal | Integration |
| `POST /api/conversations/{id}/draft_reply` | Start a reply | Team, personal | Integration |
| `POST /api/drafts` | Start a new message or forward | Team, personal | Integration |
| `GET /api/drafts/{id}` | Get a draft | Team, personal | Integration |
| `PATCH /api/drafts/{id}` | Save a draft | Team, personal | Integration |
| `DELETE /api/drafts/{id}` | Discard a draft | Personal | App |
| `POST /api/drafts/{id}/heartbeat` | Keep holding a draft | Personal | App |
| `DELETE /api/drafts/{id}/lock` | Release a draft | Personal | App |
| `POST /api/drafts/{id}/attachments` | Add a file to a draft | Personal | App |
| `DELETE /api/drafts/{id}/attachments/{attachment}` | Remove a file from a draft | Personal | App |
| `POST /api/drafts/{id}/saved_reply_files` | Copy a saved reply's files onto a draft | Personal | App |
| `POST /api/uploads` | Upload a picture | Personal | App |
| `POST /api/drafts/{id}/send` | Send a draft | Personal | App |
| `DELETE /api/drafts/{id}/send` | Cancel a send | Personal | App |
| `GET /api/events` | Stream changes | Personal | App |
| `POST /api/conversations/{id}/presence` | Set presence | Personal | App |
| `POST /api/devices` | Register a device | Personal | App |
| `DELETE /api/devices/{id}` | Remove a device | Personal | App |
| `GET /api/labels` | List labels | Team, personal | Integration |
| `POST /api/labels` | Create a label | Team, personal (team settings) | Integration |
| `PATCH /api/labels/{slug}` | Update a label | Team, personal (team settings) | Integration |
| `DELETE /api/labels/{slug}` | Delete a label | Team, personal (team settings) | Integration |
| `GET /api/members` | List members | Team, personal | Integration |
| `GET /api/mailboxes` | List mailboxes | Team, personal | Integration |
| `GET /api/mailboxes/{id}/members` | List a mailbox's members | Team, personal | Integration |
| `GET /api/saved_replies` | List saved replies | Team, personal | Integration |
| `GET /api/contacts` | Find a contact, or list contacts by a field | Team, personal | Integration |
| `GET /api/contacts/suggest` | Suggest addresses for a recipient field | Personal | App |
| `POST /api/contacts` | Create or update a contact | Team, personal | Integration |
| `GET /api/contact_fields` | List contact fields | Team, personal | Integration |
| `POST /api/contact_fields` | Create a contact field | Team, personal (team settings) | Integration |
| `PATCH /api/contact_fields/{key}` | Update a contact field | Team, personal (team settings) | Integration |
| `DELETE /api/contact_fields/{key}` | Delete a contact field | Team, personal (team settings) | Integration |
| `GET /api/card_sources` | List card sources | Team, personal (team settings) | Integration |
| `POST /api/card_sources` | Add a card source | Team, personal (team settings) | Integration |
| `PATCH /api/card_sources/{id}` | Update a card source | Team, personal (team settings) | Integration |
| `DELETE /api/card_sources/{id}` | Remove a card source | Team, personal (team settings) | Integration |
| `GET /api/conversations/{id}/cards` | Get a conversation's contact cards | Team, personal | Integration |
| `GET /api/webhooks` | List webhooks | Team, personal (team settings) | Integration |
| `POST /api/webhooks` | Add a webhook | Team, personal (team settings) | Integration |
| `GET /api/webhooks/{id}` | Get a webhook | Team, personal (team settings) | Integration |
| `PATCH /api/webhooks/{id}` | Update a webhook | Team, personal (team settings) | Integration |
| `DELETE /api/webhooks/{id}` | Remove a webhook | Team, personal (team settings) | Integration |
| `POST /api/webhooks/{id}/secret` | Rotate a webhook's secret | Team, personal (team settings) | Integration |
| `POST /api/webhooks/{id}/test` | Send a test | Team, personal (team settings) | Integration |
| `GET /api/webhooks/{id}/deliveries` | List a webhook's deliveries | Team, personal (team settings) | Integration |
| `POST /api/webhooks/{id}/deliveries/{delivery}/redeliver` | Send a delivery again | Team, personal (team settings) | Integration |
| `GET /api/conversation_fields` | List conversation fields | Team, personal | Integration |
| `POST /api/conversation_fields` | Create a conversation field | Team, personal (team settings) | Integration |
| `PATCH /api/conversation_fields/{key}` | Update a conversation field | Team, personal (team settings) | Integration |
| `DELETE /api/conversation_fields/{key}` | Delete a conversation field | Team, personal (team settings) | Integration |
| `GET /api/autoresponder` | Get the autoresponder | Team, personal | Integration |
| `PATCH /api/autoresponder` | Update the autoresponder | Team, personal (team settings) | Integration |
| `GET /api/changes` | List changes | Team, personal | Integration |
| `GET /api/statistics` | Get statistics | Team, personal | Integration |
| `GET /api/inbound` | Get inbound email setup | Team, personal | Integration |
| `POST /api/files` | Upload a file | Team, personal | Integration |
| `GET /api/attachments/{id}` | Get a file link | Team, personal | Integration |
| `GET /api/attachments/{id}/download` | Download a file | Signed link |  |
| `GET /api/draft_attachments/{id}/download` | Download a draft's file | Signed link |  |
| `GET /api/saved_reply_files/{id}/download` | Download a saved reply's file | Signed link |  |
| `GET /api/uploads/{hash}/download` | Download an uploaded picture | Signed link |  |
| `GET /api/avatars/{hash}/download` | Download an avatar | Signed link |  |
| `GET /api/team/logo/{hash}/download` | Download the team's logo | Signed link |  |
| `GET /api/remote` | Fetch a remote picture | Signed link |  |

## Changelog

Every change to the API, newest first. A breaking change applies only
from its version on: see [Versions](#description/versions).

### 2026-10-22

This is version `2026-10-22`. Version `2026-10-09` is supported until
22 October 2027.

- **Breaking.** In a `422`, each item in `errors` is an object with
  `field`, `code` and `message`, not a string. This affects every
  endpoint that can answer `422`.
- **Breaking.** `POST /api/webhooks` and `PATCH /api/webhooks/{id}`
  refuse the five event names from before `noun.verb` names:
  `new_message`, `assigned`, `note_added`, `conversation_archived` and
  `conversation_unarchived`. A webhook already subscribed to one is
  still sent it until 22 October 2027.
- The `Jelly-Version` header. Answers on version `2026-10-09` have
  `Deprecation` and `Sunset` headers.
- Webhooks can be managed through the API: `GET /api/webhooks`,
  `POST /api/webhooks`, `GET`, `PATCH` and `DELETE /api/webhooks/{id}`,
  `POST /api/webhooks/{id}/secret`, `POST /api/webhooks/{id}/test`,
  `GET /api/webhooks/{id}/deliveries` and
  `POST /api/webhooks/{id}/deliveries/{delivery}/redeliver`. Webhook
  events are named like the change feed's kinds, such as
  `message.received`.
- Address suggestions for a recipient field:
  `GET /api/contacts/suggest`.
- A person's preferences: `preferences` in `GET /api/me`, and
  `PATCH /api/me/preferences`.
- The team's logo: `team.logo` in `GET /api/me`, and
  `GET /api/team/logo/{hash}/download`.
- The change feed: `GET /api/changes`.
- File uploads: `POST /api/files`, for attachments on drafts and
  inserted messages.
- `GET /api/app`, for an app the team installed.
- The `Idempotency-Key` header on every `POST`, `PATCH` and `DELETE`.

### 2026-10-09

This is version `2026-10-09`: the API as this reference first published
it.


## Me

The person a personal token belongs to, and what this server supports.

### `GET /api/me`

Get the signed-in person and server capabilities

Accepts: **Personal token** · Tier: **App**

Returns the member, the team, the addresses they can send from, their
signature, the undo-send window and the server's capabilities.

`member.name` is the name the person set, and may be empty.

`addresses` is the set of addresses this person can send from. It is the
same set offered on the web and in the apps. An address that only some
of the team may send from is listed only for them.

Each address's `from_name` is the name a message from this person shows
in its From line. It follows the team's **From name on outgoing mail**
setting, reported in `team.from_name_format`:

| `from_name_format` | Example `from_name` |
| --- | --- |
| `person_address` | Sarah Chen (Support) |
| `person` | Sarah Chen |
| `address` | Support |

An empty `from_name` means the From line shows only the address. Use
`from_name` as given rather than building it yourself.

`team.logo` is the logo the team set on the web, or `null` when it has
none. `url` is a signed link that needs no token and expires after at
least a day; it changes every hour, so do not cache by it. `version`
changes only when the logo does. Keep the picture by `version`, and
download it again when `version` changes.

`send_delay_seconds` is how long a send waits before it goes, so it can
be undone.

`capabilities` holds what can differ from one server to another. Every
other feature described here is on every server, so a client can use it
without checking.

| Name | Meaning |
| --- | --- |
| `push` | `true` only where there is a relay to send a device's notifications through |

`sandbox` is `true` on a sandbox team: its customers are pretend, and
nothing it sends leaves. See
[A sandbox to build against](/manual/advanced/api#sandbox).

`preferences` holds the person's own choices from **Preferences** on
the web, the ones an app needs to draw its screens the same way.
`send_archive_first` says which send button leads under a reply:
**Send & archive** when `true`, **Send** when `false`. The leading
button is the one ⌘Enter presses. `forward_go_back_first` is the same
choice under a forward, between **Forward & go back** and **Forward**.
Both are `false` for a person who has never changed them. More may be
added.

`grant` is the sign-in the token belongs to: the app's `client_id`, the
name it is listed under in **Where you're signed in**, and when it
began. For an app the team registered, `grant.scopes` lists what the
person allowed it to do, so the app can tell before it asks. Any token
may call this endpoint, whatever its scopes.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "member": {
    "id": 3,
    "name": "Sarah Chen",
    "email": "sarah@acme.test",
    "role": "admin",
    "avatar": {
      "url": null,
      "initials": "SC",
      "tint": {
        "light": {
          "background": "#5e35b1",
          "text": "#ffffff"
        },
        "dark": {
          "background": "#5e35b1",
          "text": "#ffffff"
        }
      }
    }
  },
  "team": {
    "name": "Acme",
    "from_name_format": "person_address",
    "logo": {
      "url": "https://jelly.acme.test/api/team/logo/5f2c9a…/download?expires=1790089200&signature=f344…",
      "version": "5f2c9a0d81e7"
    }
  },
  "addresses": [
    {
      "id": 1,
      "address": "support@acme.test",
      "name": "Support",
      "from_name": "Sarah Chen (Support)"
    }
  ],
  "signature": "<p>Sarah</p>",
  "send_delay_seconds": 10,
  "capabilities": {
    "push": true
  },
  "sandbox": false,
  "preferences": {
    "send_archive_first": false,
    "forward_go_back_first": false
  },
  "grant": {
    "client_id": "jelly-ios",
    "name": "Jelly for iPhone",
    "created_at": "2026-03-01T09:00:00Z"
  }
}
```


### `PATCH /api/me/preferences`

Update the signed-in person's preferences

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Changes the person's own preferences, the same ones **Preferences**
changes on the web. A change made here shows on the web, and a change
made there shows in `GET /me`.

Send only the preferences to change: one left out is left as it was.
The response is every preference after the change.

A body that names no preference, names something that is not one, or
gives one a value other than `true` or `false` is refused with a `422`,
and nothing is changed. Only a person's own token may call this, since
a team token has no person to have preferences.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `send_archive_first` | boolean |  |
| `forward_go_back_first` | boolean |  |

Example body:

```json
{
  "send_archive_first": true
}
```

Answers:

- `200`: Every preference, after the change.
- `422`: The body names no preference, names something that is not one, or gives one a value that is not `true` or `false`. Nothing is changed.

Example `200`:

```json
{
  "send_archive_first": true,
  "forward_go_back_first": false
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Apps

An app installed from a manifest reads its own installation here. See
[Apps](/manual/advanced/apps) in the manual for the manifest and the
install.

### `GET /api/app`

Get the installation of the app this token belongs to

Accepts: **Personal token** · Tier: **App**

Returns the installation of the app the token belongs to: its client,
its webhook, its card source and the mailboxes the token can see.

Call it with any personal token the app holds. A token of an app that
was registered by hand, or of one of Jelly's own apps, gets a 404.

`scopes` is what the app may ask a person for. `granted_scopes` is what
this token's person allowed. A scope the app gained in an update is in
`scopes` and not in `granted_scopes` until the person allows the app
again. Any token can call this route, whatever its scopes.

`webhook` and `card_source` are `null` when the manifest asked for none,
or when an Admin has since deleted them. Neither carries its secret.
The secrets are sent to the app's `install_url` once, at install.

`mailboxes` is what this token can see, which is what its person can
see. The webhook hears the mailboxes in `webhook.mailbox_ids`, or every
mailbox when `webhook.all_mailboxes` is `true`. The Admin chose them at
install.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: The token's app was not installed from a manifest.

Example `200`:

```json
{
  "installation": {
    "id": 4,
    "name": "Acme Console",
    "client_id": "app_k3f9zq2m7p4x8r1t6w5y",
    "scopes": [
      "read",
      "write"
    ],
    "granted_scopes": [
      "read"
    ],
    "manifest_url": "https://console.acmetools.test/jelly/manifest.json",
    "manifest_hash": "9f2c…",
    "webhook": {
      "id": 2,
      "url": "https://console.acmetools.test/jelly/events",
      "events": [
        "conversation.archived",
        "note.added"
      ],
      "all_mailboxes": false,
      "mailbox_ids": [
        4
      ],
      "active": true,
      "deactivated_at": null,
      "basic_auth_username": "",
      "has_basic_auth_password": false,
      "consecutive_failures": 0,
      "last_success_at": "2026-03-01T10:31:00Z",
      "last_error": null,
      "last_error_at": null,
      "created_at": "2026-03-01T09:00:00Z",
      "updated_at": "2026-03-01T09:00:00Z"
    },
    "card_source": {
      "id": 3,
      "url": "https://console.acmetools.test/jelly/card",
      "active": true
    },
    "mailboxes": [
      {
        "id": 1,
        "name": "Support",
        "default": true
      }
    ],
    "installed_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```

Example `404`:

```json
{
  "error": "not_found",
  "message": "This token's app was not installed from a manifest."
}
```


## Inbox and lists

One person's view of the mail: their inbox, their lists and the counts
beside them. These match the web app exactly. A team token gets `403
needs_person`.

### What a row contains

Every list returns conversations as rows. A row includes:

```json
"snippet": "Thanks for getting back to us so quickly…",
"latest_note": { "author": { "type": "member", "id": 3, "name": "Mike" },
  "text": "Rang her, the parcel is at the depot", "created_at": "2026-03-04T09:20:00Z" },
"last_from": { "name": "Jane Customer", "email": "jane@customer.test", "avatar": { "…": "…" } },
"last_from_me": false,
"has_attachments": true,
"stamp": "lz3k9f:0:-"
```

- `latest_note` is the newest note when nothing newer has happened in
  the conversation. Otherwise it is `null`.
- `last_from_me` is `true` when someone on the team sent the last
  message.
- `has_attachments` counts files on messages, not on notes or unsent
  drafts.
- `stamp` changes whenever the conversation changes. Compare it with a
  cached copy to know whether to fetch the thread again. Treat it as
  opaque. Reactions, renamed labels and changed names do not change it.

A personal token also gets the reader's own marks:

```json
"unread": true, "read_at": "2026-03-04T09:00:00Z", "bookmarked": false,
"following": true, "ignored": false, "mine": true, "claimable": false
```

`ignored` means the reader set the conversation aside.

### `GET /api/inbox`

Get the inbox

Accepts: **Personal token** · Scope: `read` · Tier: **App**

Returns the inbox in four sections: Brand new, Mine, Unassigned, and
Assigned to someone else. Each section has up to 25 conversations.
`next` is a link to the full section as a paginated list.

Conversations you set aside appear in your Unassigned section.

`colour` is the section heading's colour, for light and dark themes.

| Parameter | In | Type | |
|---|---|---|---|
| `mailbox` | query | integer | Only conversations in this mailbox. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "sections": [
    {
      "key": "new",
      "title": "Brand new",
      "total": 3,
      "unread": 1,
      "colour": {
        "wash": {
          "light": "#ddf0fd",
          "dark": "#052c48"
        },
        "ink": {
          "light": "#074069",
          "dark": "#c6e5fa"
        }
      },
      "conversations": [],
      "next": "https://jelly.acme.test/api/lists/all?assigned=nobody"
    },
    {
      "key": "mine",
      "title": "Mine",
      "total": 0,
      "unread": 0,
      "conversations": [],
      "next": null
    },
    {
      "key": "unassigned",
      "title": "Unassigned",
      "total": 0,
      "unread": 0,
      "conversations": [],
      "next": null
    },
    {
      "key": "others",
      "title": "Assigned to someone else",
      "total": 0,
      "unread": 0,
      "conversations": [],
      "next": null
    }
  ]
}
```


### `GET /api/lists/{list}`

Get a list

Accepts: **Personal token** · Scope: `read` · Tier: **App**

Returns one list, 25 conversations per page.

In `drafts`, a draft for a new message has no conversation yet: its row
has `id: null` and a `draft` object. Open it with
`GET /api/drafts/{id}`. In `drafts` and `scheduled`, a reply's row also
has a `draft` object.

`scheduled` lists messages waiting to be sent, soonest first.

| Parameter | In | Type | |
|---|---|---|---|
| `list` | path | string, required | `all`, `bookmarks`, `archived`, `snoozed`, `spam`, `trash`, `sent`, `drafts`, `scheduled`, or `labels/{slug}`. |
| `assigned` | query | string: `me` \| `others` \| `nobody` | Only conversations assigned to you, to others, or to nobody. |
| `aside` | query | string | `1`: only conversations you set aside. |
| `cursor` | query | string | The `next_cursor` from the previous page. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such list.

Example `200`:

```json
{
  "conversations": [
    {
      "id": null,
      "subject": "(No subject)",
      "last_activity_at": "2026-03-04T09:12:00Z",
      "has_draft": true,
      "has_attachments": false,
      "snippet": "Notes for the wholesale sheet…",
      "draft": {
        "id": 42,
        "conversation_id": null,
        "subject": "",
        "to": [],
        "cc": [],
        "updated_at": "2026-03-04T09:12:00Z",
        "locked": false,
        "url": "https://jelly.acme.test/compose?draft=42",
        "scheduled_for": null,
        "scheduled_by": null
      }
    }
  ],
  "next_cursor": null
}
```


### `GET /api/sidebar`

Get counts, mailboxes and labels

Accepts: **Personal token** · Scope: `read` · Tier: **App**

Returns the counts, mailboxes and labels shown beside the lists.
`drafts` and `scheduled` count the drafts the person can see, including
other people's.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "counts": {
    "inbox": 3,
    "drafts": 2,
    "scheduled": 1,
    "snoozed": 0
  },
  "bookmarks": 2,
  "mailboxes": [
    {
      "id": 1,
      "name": "Support",
      "default": true
    }
  ],
  "labels": [
    {
      "id": "billing",
      "name": "Billing",
      "colour": "lemon",
      "created_at": "2026-01-02T10:00:00Z"
    }
  ]
}
```


## Conversations

Listing, searching and reading conversations. A token only sees
conversations in its own mailboxes. Any other conversation returns
`404`.

`status` is one of `open`, `archived`, `snoozed`, `spam` or `trash`.
Jelly shows open conversations in the inbox, so `inbox` also works as a
filter for `open`.

A conversation that has been `spam` or `trash` for 30 days is deleted,
with everything in it. After that it returns `404`, and no change says
so. The 30 days start when it moved there: its last
`conversation.spammed` or `conversation.trashed` change, or its arrival
for a conversation that arrived as spam. Delete your copy then too.

`contact` is the person the conversation is with: the contact on its
first message.

`external_id` is your own id for the conversation, such as an order or
a ticket in your system. Only one conversation can have a given
`external_id`. A single conversation, and a row with `include=fields`,
always has the key, `null` until you set it. Other rows have it only
when it is set.

`fields` holds the conversation's values for the team's conversation
fields (see Conversation fields), by key. A field with no value is not
in `fields`. A single conversation always has `fields`. List and search
rows have them only with `include=fields`.

### `GET /api/conversations`

List conversations

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists conversations, newest activity first. Spam, trash and vaulted
conversations are left out unless you ask for them with `status`.

- `external_id` returns the one conversation with that external id.
- `field.<key>=<value>` returns conversations whose field has that
  value, such as `field.plan=enterprise`. Text is matched whatever its
  capitals. An empty value matches any value. Several filters must all
  match. A key that is not a conversation field is a `422`.
- `include=fields` adds `fields` to each row.

The same filters work on `GET /conversations/search`.

| Parameter | In | Type | |
|---|---|---|---|
| `status` | query | string: `open` \| `archived` \| `snoozed` \| `spam` \| `trash` \| `vaulted` | Only conversations with this status. `inbox` means `open`. Without it, spam, trash and the vault are left out. |
| `label_id` | query | string | Only conversations with this label. |
| `mailbox_id` | query | integer | Only conversations in this mailbox. |
| `external_id` | query | string | Only the conversation with this external id. |
| `field.<key>` | query | string | Only conversations whose field `<key>` has this value, such as `field.plan=enterprise`. Empty: any value. Give several to match them all. |
| `include` | query | string: `fields` | `fields`: each row includes `fields`. Without it, rows leave them out. |
| `updated_since` | query | string | Only conversations changed at or after this time (RFC 3339), oldest change first. Spam and trash are included unless `status` says otherwise. |
| `limit` | query | integer | Results per page, 1 to 100. The default is 50. |
| `cursor` | query | string | The `next_cursor` from the previous page. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `422`: A `status` it does not know, a `limit` out of range, a `field.<key>` that is not a conversation field, a value that field could not hold, or an `updated_since` that is not a time.

Example `200`:

```json
{
  "conversations": [
    {
      "id": "broken-widget-k3f9zq",
      "external_id": "order-1234",
      "subject": "Broken widget",
      "status": "open",
      "fields": {
        "plan": "enterprise"
      },
      "…": "…"
    }
  ],
  "next_cursor": "MTcwMDAwMDAwMA"
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `GET /api/conversations/search`

Search conversations

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Searches conversations. `q` takes the same text and operators as the
web app's search box; see
[Finding conversations](/manual/basics/finding#operators).

- Spam, trash and vaulted conversations are left out unless `q` has
  `in:spam`, `in:trash` or `in:vault`, or you set `status`.
- `label_id` and `mailbox_id` narrow the search further.
- `is:` operators (such as `is:unread`) need a personal token. A team
  token gets `422`.
- A `q` with only operators returns every match, newest first. A `q`
  with words returns the best matches first, up to 200.

Results are rows, as lists return them. With `include_matches=true`,
each has a `match`: the matching text, with the matched words marked.

| Parameter | In | Type | |
|---|---|---|---|
| `q` | query | string, required | The search, with operators, such as `refund from:jane@customer.test` or `is:unread`. |
| `status` | query | string: `open` \| `archived` \| `snoozed` \| `spam` \| `trash` \| `vaulted` | Only conversations with this status. `inbox` means `open`. Without it, spam, trash and the vault are left out. |
| `label_id` | query | string | Only conversations with this label. |
| `mailbox_id` | query | integer | Only conversations in this mailbox. |
| `external_id` | query | string | Only the conversation with this external id. |
| `field.<key>` | query | string | Only conversations whose field `<key>` has this value, such as `field.plan=enterprise`. Empty: any value. Give several to match them all. |
| `include` | query | string: `fields` | `fields`: each row includes `fields`. Without it, rows leave them out. |
| `include_matches` | query | boolean | `true`: each result includes `match`, the text that matched. |
| `limit` | query | integer | Results per page, 1 to 100. The default is 50. |
| `cursor` | query | string | The `next_cursor` from the previous page. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `422`: No `q`, or `is:` from a team token.

Example `200`:

```json
{
  "conversations": [
    {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget",
      "match": "the <mark>widget</mark> arrived broken",
      "…": "…"
    }
  ],
  "next_cursor": null
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `GET /api/conversations/{id}`

Get a conversation

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Returns one conversation with up to 20 recent messages.

It also says how a reply would start:

```json
"reply_from": { "id": 2, "address": "sales@acme.test", "name": "Sales" },
"reply": { "message_id": "k3f9zq", "to": ["Sam <sam@example.test>"], "cc": [] }
```

- `reply_from` is the address a reply is sent from, or `null` if the
  team cannot send at all.
- `reply` is who a reply goes to, and which message it answers. It is
  empty when there is nothing to answer.

`POST .../draft_reply` uses these when you give it no recipients.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `PATCH /api/conversations/{id}`

Update a conversation's fields and external id

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Sets a conversation's `fields` and `external_id`. Nothing else can be
changed here.

- `fields` sets values by key. Keys you leave out keep their values, and
  `null` clears one. A key that is not a conversation field, or a value
  of the wrong type, is a `422` that names the field, and nothing is
  changed.
- `external_id` sets the external id. `null` or `""` clears it. If
  another conversation has it, the answer is `409` and nothing is
  changed.

Each value that changes adds a line to the conversation's timeline,
such as "set Plan to “enterprise”". It does not move the conversation
up the inbox.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `fields` | object | Field values, by the field's key. A key left out keeps its value. `null` clears one. |
| `external_id` | string, or null | Your own id for this conversation, up to 255 characters. `null` or `""` clears it. |

Example body:

```json
{
  "external_id": "order-1234",
  "fields": {
    "plan": "enterprise",
    "app_version": null
  }
}
```

Answers:

- `200`: The conversation, with its `fields`.
- `404`: No such conversation, or not one this caller can see.
- `409`: Another conversation has this `external_id`. Nothing is changed.
- `422`: A key in `fields` that is not a conversation field, a value of the wrong type, or any other key in the body. Nothing is changed.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `GET /api/conversations/{id}/thread`

Get a conversation's thread

Accepts: **Personal token** · Scope: `read` · Tier: **App**

Returns the whole conversation as the web app shows it to this person:
the conversation, `seen_at` (the person's previous visit), `stamp`,
`links_expire_at`, `presence`, and `items` in order.

Reading a thread does not mark it read. Use `POST .../read` for that.

#### Items

Each item has a `kind`:

- `message`: a message. `html` is cleaned and has quoted text folded
  into a `<details class="jam-quote">` element. `remote_images_blocked`
  counts blocked pictures; add `?remote_images=1` to show them.
- `note`: an internal note, with its `attachments`, `reactions` and
  `deletable_until`.
- `activity`: a line of the timeline, such as "assigned this to Sarah".
  `actor` and `target` say who, so you can write your own sentence.
- `scheduled`: a reply waiting to be sent. `scheduled_for` is when it
  goes. `DELETE /api/drafts/{draft_id}/send` cancels it.
- `draft`: a reply being written. `locked` says whether someone has it
  open, and `editor` who. Use `updated_at` as `if_updated_at` when you
  save it.

A thread ends with at most one `scheduled` or `draft` item.

#### Links in a thread

Files, inline pictures and remote pictures in a thread use signed links
that need no token, so an image view can load them directly. Signed
links expire. `links_expire_at` is the earliest expiry in the response.
After that time, fetch the thread again without `If-None-Match`. A
`410` from a link means the same thing.

#### Caching

The response has an `ETag`. Send it back as `If-None-Match`; if nothing
has changed, the answer is `304` with no body. Treat the `ETag` as
opaque. It also changes for things that do not change `stamp`, such as
reactions and drafts.

#### Presence

`presence` lists the other people who have the conversation open now,
and whether each is `viewing`, `replying` or `commenting` (writing a
note). It never includes you.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `remote_images` | query | string | `1`: show pictures from other websites. |
| `If-None-Match` | header | string | The `ETag` of your cached copy. If nothing changed, the answer is `304`. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `304`: Not changed since your cached copy.
- `404`: No such conversation, or not one this caller can see.

Example `200`:

```json
{
  "conversation": {
    "id": "broken-widget-k3f9zq",
    "subject": "Broken widget",
    "…": "…",
    "reply_from": {
      "id": 1,
      "address": "support@acme.test",
      "name": "Support"
    },
    "reply": {
      "message_id": "k3f9zq",
      "to": [
        "Jane Customer <jane@customer.test>"
      ],
      "cc": []
    }
  },
  "seen_at": "2026-03-04T08:30:00Z",
  "stamp": "lz3k9f:0:-",
  "links_expire_at": "2026-03-04T09:05:00Z",
  "presence": [
    {
      "member": {
        "type": "member",
        "id": 4,
        "name": "Bob"
      },
      "status": "replying"
    }
  ],
  "items": [
    {
      "kind": "message",
      "id": "k3f9zq",
      "direction": "inbound",
      "from": {
        "name": "Jane Customer",
        "email": "jane@customer.test"
      },
      "to": [],
      "cc": [],
      "sent_at": "2026-03-04T08:00:00Z",
      "html": "<p>My widget arrived broken.</p>",
      "text": "My widget arrived broken.",
      "remote_images_blocked": 0,
      "attachments": [
        {
          "id": 9,
          "filename": "photo.jpg",
          "url": "https://jelly.acme.test/api/attachments/9/download?expires=1772615100&signature=…"
        }
      ],
      "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq?focus=m-k3f9zq#m-k3f9zq"
    },
    {
      "kind": "note",
      "id": "n8x2",
      "author": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen"
      },
      "html": "<p>Sending a new one</p>",
      "at": "2026-03-04T08:10:00Z",
      "attachments": [],
      "reactions": [],
      "deletable_until": null,
      "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq?focus=n-n8x2#n-n8x2"
    },
    {
      "kind": "activity",
      "verb": "assigned",
      "line": "assigned this to <span data-member=\"3\" data-you=\"you\">Sarah Chen</span>",
      "actor": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen"
      },
      "target": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen"
      },
      "at": "2026-03-04T08:11:00Z"
    }
  ]
}
```


### `GET /api/conversations/{id}/markdown`

Get a conversation as markdown

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Returns the whole conversation as markdown: every message and note,
oldest first.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: The conversation, as markdown.
- `404`: No such conversation, or not one this caller can see.

Example `200`:

```
# Broken widget

**Jane Customer** <jane@customer.test>, 4 Mar 2026 08:00

My widget arrived broken.
```


### `GET /api/conversations/{id}/messages`

List a conversation's messages

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Returns every message in a conversation, oldest first, one page at a
time. Each message is in the same form as `GET /api/messages/{key}`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `limit` | query | integer | Results per page, 1 to 100. The default is 50. |
| `cursor` | query | string | The `next_cursor` from the previous page. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such conversation, or not one this caller can see.

Example `200`:

```json
{
  "messages": [
    {
      "id": "k3f9zq",
      "conversation_id": "broken-widget-k3f9zq",
      "direction": "inbound",
      "subject": "Broken widget",
      "…": "…"
    }
  ],
  "next_cursor": null
}
```


### `POST /api/conversations/bulk`

Change many conversations at once

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Applies one action to many conversations at once. Either every
conversation changes or none does. If any id is not a conversation the
person can see, the answer is `404` and nothing changes.

| `action` | Effect | Also needs |
|---|---|---|
| `archive`, `unarchive` | Archives, or moves back to the inbox | |
| `trash`, `untrash` | Moves to the trash, or restores | |
| `spam`, `unspam` | Marks as spam, or not spam | |
| `read`, `unread` | Marks read or unread for this person | |
| `assign_me`, `unassign_me` | Assigns to this person, or unassigns | |
| `label_add`, `label_remove` | Adds or removes a label | `label` |
| `snooze` | Snoozes | `until` |

`ids` can have up to 200 conversations. The answer has every row after
the change, in the order you asked.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `action` | string: `archive` \| `unarchive` \| `trash` \| `untrash` \| `spam` \| `unspam` \| `read` \| `unread` \| `assign_me` \| `unassign_me` \| `label_add` \| `label_remove` \| `snooze`, required |  |
| `ids` | array of string, required | Conversation ids, up to 200. |
| `label` | string | A label id, for `label_add` and `label_remove`. |
| `until` | date-time | When the snooze ends, for `snooze`. |

Example body:

```json
{
  "action": "archive",
  "ids": [
    "order-did-not-arrive-k3f9",
    "refund-request-k8d2"
  ]
}
```

Answers:

- `200`: The conversations after the change, in the order given.
- `404`: An id is not a conversation this person can see. Nothing changed.
- `422`: An action it does not know, a missing label, a `snooze` without a future `until`, or more than 200 ids.

Example `200`:

```json
{
  "conversations": [
    {
      "id": "order-did-not-arrive-k3f9",
      "status": "archived"
    }
  ]
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Conversation actions

Changing a conversation. Each action is recorded on the conversation's
timeline and triggers webhooks, as the same action in the web app does.
Each returns the conversation after the change.

An action by a team token is recorded under the token's name.

### `POST /api/conversations/{id}/archive`

Archive

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Archives the conversation.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `DELETE /api/conversations/{id}/archive`

Unarchive

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Moves the conversation back to the inbox.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/trash`

Move to the Trash

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Moves the conversation to the trash.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `DELETE /api/conversations/{id}/trash`

Restore from the Trash

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Restores the conversation from the trash.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/spam`

Mark as spam

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Marks the conversation as spam.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `DELETE /api/conversations/{id}/spam`

Mark as not spam

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Marks the conversation as not spam.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/snooze`

Snooze

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Snoozes the conversation until `until`, which must be in the future.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `until` | date-time, required | When the snooze ends. Must be in the future. |

Example body:

```json
{
  "until": "2026-03-05T09:00:00Z"
}
```

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.
- `422`: No `until`, or one that is not in the future.

Example `200`: the shared example `conversation`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/conversations/{id}/snooze`

End a snooze

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Ends the snooze now.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/labels`

Add a label

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Adds a label. Returns `201` if the label was added, or `200` if it was
already there.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `label_id` | string, required | The label's id. |

Example body:

```json
{
  "label_id": "billing"
}
```

Answers:

- `201`: The label was added.
- `200`: The label was already there.
- `404`: No such conversation, or not one this caller can see.
- `422`: No `label_id`, or one that names no label.

Example `201`: the shared example `conversation`, under Shared examples at the end.

Example `200`: the shared example `conversation`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/conversations/{id}/labels/{label}`

Remove a label

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Removes a label. Returns `200` even if the label was not there.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `label` | path | string, required | The label's id: its slug, which does not change when the label is renamed. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/assignments`

Assign

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Assigns the conversation to a member, given by `member_id` or `email`.
With a personal token, `{"member": "me"}` assigns it to yourself.
Returns `201` for a new assignment, or `200` if it already existed.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `member_id` | integer | The member's id. |
| `email` | string | Or the member's email address. |
| `member` | string: `me` | Or `me`, with a personal token. |

Example body:

```json
{
  "member_id": 3
}
```

Answers:

- `201`: Assigned.
- `200`: Already assigned to this member.
- `404`: No such conversation, or not one this caller can see.
- `422`: Nobody named, a member who is not active, or `me` from a team token.

Example `201`: the shared example `conversation`, under Shared examples at the end.

Example `200`: the shared example `conversation`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/conversations/{id}/assignments/{member}`

Unassign

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Unassigns a member, given by id, email, or `me` with a personal token.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `member` | path | string, required | Who to take off: a member's id, their email address, or `me` (a person's own token only). |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.
- `422`: `me` from a team token, or nobody it can name.

Example `200`: the shared example `conversation`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `PATCH /api/conversations/{id}/mailboxes`

Set mailboxes

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Sets the mailboxes the conversation is in. A token can only add or
remove its own mailboxes. Asking to change another mailbox returns
`422`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `mailbox_ids` | array of integer, required | Every mailbox the conversation should be in. |

Example body:

```json
{
  "mailbox_ids": [
    1,
    2
  ]
}
```

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.
- `422`: A mailbox this caller may not move it into or out of, or none at all.

Example `200`: the shared example `conversation`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Your own marks

Marks that belong to one person: read, bookmarked, followed and set
aside. Each returns the conversation after the change. A team token gets
`403 needs_person`.

### `POST /api/conversations/{id}/read`

Mark read

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Marks the conversation read, as opening it in the web app does. Also
clears this person's notifications for it. `previous_seen_at` is when
the person last saw it, or `null`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: When this person last saw it.
- `404`: No such conversation, or not one this caller can see.

Example `200`:

```json
{
  "previous_seen_at": "2026-03-03T17:00:00Z"
}
```


### `DELETE /api/conversations/{id}/read`

Mark unread

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Marks the conversation unread.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/bookmark`

Bookmark

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Bookmarks the conversation.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `DELETE /api/conversations/{id}/bookmark`

Remove bookmark

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Removes the bookmark.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/follow`

Follow

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Follows the conversation, so this person is notified about it.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `DELETE /api/conversations/{id}/follow`

Stop following

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Stops following the conversation.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


### `POST /api/conversations/{id}/aside`

Set aside

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Sets the conversation aside: it is marked read and moves from this
person's Brand new to their Unassigned. Nobody else's inbox changes.

Only a conversation in this person's Brand new can be set aside.
Anything else returns `409 not_brand_new`. Setting aside a conversation
that is already set aside returns `200` and changes nothing.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.
- `409`: Not in this person's Brand new.

Example `200`: the shared example `conversation`, under Shared examples at the end.

Example `409`:

```json
{
  "error": "not_brand_new",
  "message": "Only a conversation in your Brand new can be set aside. This one is already somebody's, or no longer open."
}
```


### `DELETE /api/conversations/{id}/aside`

Undo set aside

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Undoes setting the conversation aside.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The conversation after the change.
- `404`: No such conversation, or not one this caller can see.

Example `200`: the shared example `conversation`, under Shared examples at the end.


## Notes

Internal notes on a conversation, and reactions to them. A team token
can read and write notes. Only a personal token can delete notes and
react to them.

### Mentions

Mention a member with `@` and their handle. The handle is the part of
their email address before the `@`, such as `@sarah`. If two active
members share that part, use the whole address, such as
`@sam@acme.test`. The whole address always works.

A mention notifies the member and makes them follow the conversation. A
member who cannot see the conversation is not mentioned. In the stored
note, a mention becomes `<span class="jam-mention">@Sarah Chen</span>`.

How the server finds mentions in a note's HTML:

1. Only text is read. Tags, attributes and link text are ignored.
2. A mention is `@` followed by letters, digits or `. _ % + -`,
   optionally followed by `@` and a domain.
3. The `@` must start a word: it is at the start of the text, or after
   a character that is not a letter, digit or one of `. _ % + - / @ =`.
4. The handle matches an active member's address, ignoring case. A
   short handle shared by two members matches nobody.
5. If the handle matches nobody and ends in `. _ % + -`, that last
   character is dropped and the match is tried again. So
   "Thanks @sam." mentions sam.

### `GET /api/conversations/{id}/notes`

List notes

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists a conversation's notes, oldest first. Each note includes its
`reactions` and `deletable_until`. File links here need a token; the
thread's file links are signed.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `limit` | query | integer | Results per page, 1 to 100. The default is 50. |
| `cursor` | query | string | The `next_cursor` from the previous page. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such conversation, or not one this caller can see.

Example `200`:

```json
{
  "notes": [
    {
      "id": "n8x2",
      "conversation_id": "broken-widget-k3f9zq",
      "html_body": "<p>Sending a new one</p>",
      "text_body": "Sending a new one",
      "created_at": "2026-03-04T08:10:00Z",
      "edited_at": null,
      "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq?focus=n-n8x2#n-n8x2",
      "author": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen"
      },
      "attachments": [],
      "reactions": [
        {
          "id": 31,
          "emoji": "👍",
          "member": {
            "type": "member",
            "id": 4,
            "name": "Bob"
          },
          "mine": false
        }
      ],
      "deletable_until": null
    }
  ],
  "next_cursor": null
}
```


### `POST /api/conversations/{id}/notes`

Add a note

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Writes a note. A note must have text.

To attach files, upload each one first with `POST /api/files` and list
their ids in `attachments`. Or send `multipart/form-data` with a `body`
part and one `attachments` part per file. Either way, a note can have
up to 20 files, totalling 10 MB. More files returns `422`, and more
bytes returns `413`. A file that is already
attached returns `409`, and one this caller did not upload returns
`404`. Either way, nothing is saved.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `body` | string, required | The note, as HTML or plain text. `html_body` also works. |
| `attachments` | array of string | Ids of files from `POST /files`. In a multipart request, one file part each instead. At most 20 files and 10 MB in total either way. |

Example body:

```json
{
  "body": "<p>Rang her, the parcel is at the depot. @sarah can you chase?</p>"
}
```

Answers:

- `201`: The note.
- `404`: No such conversation, or not one this caller can see, or a file in `attachments` that this caller did not upload or that has expired.
- `409`: A file from `POST /files` is already attached. Nothing was saved.
- `413`: The files are over 10 MB in total. Nothing was saved.
- `422`: No words, more than 20 files, or `attachments` is not a list of distinct file ids.

Example `201`:

```json
{
  "note": {
    "id": "n8x2",
    "conversation_id": "broken-widget-k3f9zq",
    "html_body": "<p>…</p>",
    "text_body": "…",
    "created_at": "2026-03-04T09:20:00Z",
    "edited_at": null,
    "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq?focus=n-n8x2#n-n8x2",
    "author": {
      "type": "api_token",
      "id": 7,
      "name": "The night shift"
    },
    "attachments": [
      {
        "id": 12,
        "filename": "screenshot.png",
        "content_type": "image/png",
        "byte_size": 48213,
        "inline": false,
        "url": "https://jelly.acme.test/api/attachments/12"
      }
    ],
    "reactions": [],
    "deletable_until": null
  }
}
```

Example `409`:

```json
{
  "error": "already_attached",
  "message": "That file is already attached to something. Upload it again to attach it twice"
}
```

Example `413`:

```json
{
  "error": "too_large",
  "message": "Attachments must total under 10 MB"
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/conversations/{id}/notes/{note}`

Delete a note

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Deletes a note, with its files and reactions. Only the person who wrote
it can delete it, and only for five minutes after posting.
`deletable_until` says when that ends, or is `null` if this person
cannot delete it.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `note` | path | string, required | The note's id, as the notes list and the thread hand it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Deleted, with its files and reactions.
- `403`: Someone else wrote the note.
- `404`: No such note.
- `409`: More than five minutes have passed since the note was posted.

Example `403`:

```json
{
  "error": "forbidden",
  "message": "A note can be deleted by whoever wrote it, for a few minutes after posting"
}
```

Example `409`:

```json
{
  "error": "too_late",
  "message": "A note can be deleted by whoever wrote it, for a few minutes after posting"
}
```


### `POST /api/conversations/{id}/notes/{note}/reactions`

Add a reaction

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Adds a reaction to a note. The emoji must be one from
`GET /api/reactions`. Reactions do not notify anyone.

The answer has the new reaction and all of the note's reactions. In
`reactions`, `mine` is `true` for the reactions this person can change.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `note` | path | string, required | The note's id, as the notes list and the thread hand it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `emoji` | string, required | An emoji from `GET /api/reactions`. |

Example body:

```json
{
  "emoji": "👍"
}
```

Answers:

- `201`: The new reaction, and all of the note's reactions.
- `404`: No such note.
- `422`: Not an emoji from `GET /api/reactions`.

Example `201`:

```json
{
  "reaction": {
    "id": 33,
    "emoji": "👍",
    "member": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "mine": true
  },
  "reactions": [
    {
      "id": 31,
      "emoji": "🎉",
      "member": {
        "type": "member",
        "id": 4,
        "name": "Bob"
      },
      "mine": false
    },
    {
      "id": 33,
      "emoji": "👍",
      "member": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen"
      },
      "mine": true
    }
  ]
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": null,
      "code": "invalid",
      "message": "That's not a reaction Jelly knows about"
    }
  ]
}
```


### `PATCH /api/conversations/{id}/notes/{note}/reactions/{reaction}`

Change a reaction

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Changes one of your reactions to another emoji. The changed reaction
gets a new `id` and moves to the end of the list.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `note` | path | string, required | The note's id, as the notes list and the thread hand it out. |
| `reaction` | path | string, required | The reaction's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `emoji` | string, required | An emoji from `GET /api/reactions`. |

Example body:

```json
{
  "emoji": "🎉"
}
```

Answers:

- `200`: The changed reaction, with a new id, and all of the note's reactions.
- `404`: No such reaction by this person.
- `422`: Not an emoji from `GET /api/reactions`.

Example `200`:

```json
{
  "reaction": {
    "id": 34,
    "emoji": "🎉",
    "member": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "mine": true
  },
  "reactions": [
    {
      "id": 34,
      "emoji": "🎉",
      "member": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen"
      },
      "mine": true
    }
  ]
}
```


### `DELETE /api/conversations/{id}/notes/{note}/reactions/{reaction}`

Remove a reaction

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Removes one of your reactions. The answer has the note's remaining
reactions.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `note` | path | string, required | The note's id, as the notes list and the thread hand it out. |
| `reaction` | path | string, required | The reaction's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The note's remaining reactions.
- `404`: No such reaction by this person.

Example `200`:

```json
{
  "reactions": []
}
```


### `GET /api/reactions`

List reaction emoji

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists every emoji a note can be reacted with, in groups, each with its
name.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "groups": [
    {
      "name": "Frequently used",
      "reactions": [
        {
          "emoji": "👍",
          "name": "thumbs up"
        },
        {
          "emoji": "👎",
          "name": "thumbs down"
        }
      ]
    },
    {
      "name": "Smileys & people",
      "reactions": [
        {
          "emoji": "😀",
          "name": "grinning face"
        }
      ]
    }
  ]
}
```


## Messages

A single message.

### `GET /api/messages/{key}`

Get a message

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Returns a message with its files, its headers, and the result of the
sender checks:

```json
"authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass" }
```

Each check is `null` if the original message is not stored.

#### Delivery

An outbound message also has `delivery`: whether it arrived.

```json
"delivery": {
  "state": "bounced", "label": "Bounced", "confirmed": 0, "expected": 1,
  "issues": [{
    "state": "bounced", "label": "Bounced", "recipient": "nobody@customer.test",
    "said": "It was refused permanently: as far as customer.test is concerned, nobody@customer.test is not an address that exists.",
    "todo": "Check it for a typo and send again to the right one, or ask them for another address. Sending to this one again will bounce again.",
    "detail": "smtp;550 5.1.1 User unknown", "at": "2026-03-04T09:12:00Z"
  }]
}
```

`state` is one of `delivered`, `sent`, `partly_delivered`, `bounced`,
`deferred`, `spam_complaint`, `blocked`, `unsubscribed`, `suppressed`,
`auth_failed`, `rate_limited`, `quota_exceeded`, `provider_down`,
`refused` or `unknown`.

On a [sandbox team](/manual/advanced/api#sandbox), nothing is sent. The
state is `sent` and the label is `Would have been sent`.

`confirmed` is how many recipients the sending service confirmed, out of
`expected`. Some services report nothing back; then `expected` is `0`
and the state is `sent`.

`issues` has one entry for each recipient with a problem. `said` and
`todo` are written to be shown to a person. See
[Did it arrive?](/manual/basics/delivery).

| Parameter | In | Type | |
|---|---|---|---|
| `key` | path | string, required | The message's key, as a thread or a message list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such message, or not visible to this token.

Example `200`:

```json
{
  "message": {
    "id": "k9x2",
    "conversation_id": "broken-widget-k3f9zq",
    "direction": "outbound",
    "sender_type": "member",
    "from": {
      "name": "Sarah Chen",
      "email": "support@acme.test"
    },
    "to": [
      {
        "name": "Jane Customer",
        "email": "jane@customer.test"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Broken widget",
    "snippet": "A new one is on its way",
    "text_body": "A new one is on its way.",
    "html_body": "<p>A new one is on its way.</p>",
    "automatic": false,
    "message_id": "<k9x2@acme.test>",
    "in_reply_to": "<CA+abc@customer.test>",
    "sent_at": "2026-03-04T09:12:00Z",
    "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq?focus=m-k9x2#m-k9x2",
    "attachments": [],
    "headers": {
      "Subject": "Re: Broken widget"
    },
    "authentication": {
      "spf": null,
      "dkim": null,
      "dmarc": null
    },
    "delivery": {
      "state": "delivered",
      "label": "Delivered",
      "confirmed": 1,
      "expected": 1,
      "issues": []
    }
  }
}
```


### `POST /api/messages`

Insert a message

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Only for a team with this feature turned on. Otherwise the route answers `403 plan`.

Puts a message into the team's mail, as if it had arrived by email.
A team has it only when we have turned it on for them. Until then the
route answers `403` with the error `plan`.

Without `reply_to`, the message starts a new conversation. With
`reply_to`, it is added to the conversation of the message with that
key, as a reply to it.

If `from` is one of the team's addresses, the message is outbound.
Otherwise it is inbound, and `to` or `cc` must include one of the
team's addresses. Nothing is sent, either way. A team token and a
personal token can both insert outbound messages.

An inbound message is treated as new mail: rules run, followers are
notified, webhooks and chat posts go out, and the autoresponder may
reply. A message from a blocked contact is accepted and handled as
their mail is: it goes to spam.

A new conversation starts in the default mailbox, or in `mailbox_id`.
The caller must be able to use that mailbox; if it cannot, the answer
is `422`. A token that was not given the default mailbox must send
`mailbox_id`. A rule may still move the conversation to a mailbox the
caller cannot see; then the answer's `message` is `null`.

`fields` and `external_id` are set on the message's conversation before
rules run, so a rule can match on them. They add no line to the
conversation's timeline. `fields` is checked first: a key that is not a
conversation field, or a value of the wrong type, is a `422`, and
nothing is delivered. If another conversation has the `external_id`,
the answer is `409` and nothing is delivered. A reply into the
conversation that has it is allowed.

`contact` sets the message's contact: its `name`, its `external_id`
and its contact `fields`. The contact is the sender of an inbound
message, or the first recipient outside the team of an outbound one.
It is created if it does not exist. The values are set before rules
run. They are checked the same way: a wrong field is a `422`, and an
`external_id` another contact has is a `409`. Nothing is delivered
then. A `null` or empty `external_id` leaves the contact's own as it
is.

To attach files, upload each one first with `POST /api/files` and list
their ids in `attachments`. They are added to the message as mail
attachments. Up to 20 files, totalling 10 MB; more returns `422` or
`413`. A file that is already attached returns `409`, and one this
caller did not upload returns `404`. Either way, nothing is delivered.

Send an `Idempotency-Key` header to retry safely: a retry with the same
key gets the first answer again and makes no second conversation (see
Retrying safely).

A field that goes into a mail header (`from`, `to`, `cc`, `subject`,
`reply_to`) must not hold a line break. The request is refused with a
422 if one does.

`sent_at` may be in the past; the message is placed at that time.

The message comes back with `source` set to `api`, and `inserted_by`
naming the token or member that put it in. Its `authentication` checks
are `null`: no mail server checked it.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `from` | object, required | `{"name": "…", "email": "…"}`. One of the team's own addresses makes the message outbound. |
| `to` | recipients, required | An array of addresses, or one comma-separated string. For an inbound message, `to` or `cc` must include one of the team's addresses. |
| `cc` | recipients |  |
| `subject` | string |  |
| `text` | string | The body as plain text. `text`, `html` or both. |
| `html` | string | The body as HTML. |
| `reply_to` | string | The key of the message this replies to. Without it, the message starts a new conversation. |
| `fields` | object | Conversation field values, by key, set before rules run. |
| `external_id` | string | Your own id for the conversation, set before rules run. |
| `contact` | object | `{"name", "external_id", "fields"}`, set on the message's contact before rules run. The contact is the sender of an inbound message, or the first recipient outside the team of an outbound one. It is created if it does not exist. |
| `mailbox_id` | integer | The mailbox a new conversation starts in. Defaults to the default mailbox. It must be one the caller can use. Not with `reply_to`. |
| `attachments` | array of string | Ids of files from `POST /files`, at most 20 and 10 MB in total. They become the message's attachments. |
| `sent_at` | date-time | When it was written, as RFC 3339. Defaults to now. A time in the future is taken as now. |

Example body:

```json
{
  "from": {
    "name": "Jane Customer",
    "email": "jane@customer.test"
  },
  "to": [
    "support@acme.test"
  ],
  "subject": "Broken widget",
  "text": "My widget arrived broken.",
  "external_id": "order-1234",
  "fields": {
    "plan": "enterprise"
  },
  "contact": {
    "name": "Jane Customer",
    "external_id": "user-881",
    "fields": {
      "seats": 12
    }
  },
  "attachments": [
    "file_k3f9zq2m7xw4b8n5c6d2"
  ]
}
```

Answers:

- `201`: The new message. `message` is `null` if it went to a mailbox this token cannot see.
- `403`: The team does not have this feature turned on.
- `404`: `reply_to` names no message this caller can see, or a file in `attachments` was not uploaded by this caller or has expired.
- `409`: Another conversation has this `external_id`, another contact has `contact.external_id`, or a file in `attachments` is already attached. Nothing is delivered.
- `413`: The files are over 10 MB in total. Nothing is delivered.
- `422`: A field is missing or cannot be read, a header field holds a line break, an inbound message names none of the team's addresses, a key in `fields` or `contact.fields` is not a field of that kind or holds a value of the wrong type, `mailbox_id` is not a mailbox the caller can use, a new conversation would start in the default mailbox and the caller cannot use it, or `attachments` is not a list of up to 20 distinct file ids. Nothing is delivered.

Example `201`:

```json
{
  "message": {
    "id": "p4t7",
    "conversation_id": "broken-widget-k3f9zq",
    "direction": "inbound",
    "sender_type": "contact",
    "from": {
      "name": "Jane Customer",
      "email": "jane@customer.test"
    },
    "to": [
      {
        "name": "",
        "email": "support@acme.test"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Broken widget",
    "snippet": "My widget arrived broken.",
    "text_body": "My widget arrived broken.",
    "html_body": "<p>My widget arrived broken.</p>",
    "automatic": false,
    "message_id": "<5f0c2a9e1b7d4e3c8a6f9b21@demo.jam.invalid>",
    "in_reply_to": "",
    "sent_at": "2026-03-04T09:20:00Z",
    "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq?focus=m-p4t7#m-p4t7",
    "source": "api",
    "inserted_by": {
      "type": "api_token",
      "id": 7,
      "name": "Acme Console"
    },
    "attachments": [
      {
        "id": "a3",
        "filename": "photo.jpg",
        "…": "…"
      }
    ],
    "headers": {
      "Subject": "Broken widget"
    },
    "authentication": {
      "spf": null,
      "dkim": null,
      "dmarc": null
    }
  }
}
```

Example `403`:

```json
{
  "error": "plan",
  "message": "Putting messages in through the API is not turned on for your team."
}
```

Example `413`:

```json
{
  "error": "too_large",
  "message": "Attachments must total under 10 MB"
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Drafts

Writing replies, new messages and forwards, with their files and
pictures. Everyone who can see a draft's mailbox can see the draft, but
only one person can edit it at a time.

A team token can create, read and save drafts, but not send them. A
draft a team token creates is not held by anyone, so the first person
to open it can finish it. Discarding a draft, its files, the heartbeat,
releasing the lock and sending all need a personal token.

### Holding a draft

Only the person who holds a draft can change its files or send it. When
a personal token creates or saves a draft, it holds the draft and gets a
`lock_token`. Send the `lock_token` with the requests that need it.

The hold lasts two minutes after the last save or heartbeat. `423
locked` means someone else holds the draft; the answer says who.

### Recipients

`to`, `cc` and `bcc` are arrays of addresses, such as
`Jane Customer <jane@customer.test>`. You can also send one
comma-separated string. An address that cannot be read returns `422`
and nothing is saved. Send `[]` to clear a field.

### From address

`from` is the address the draft will be sent from, in the same form as
`GET /api/me` lists addresses. It is `null` if the team can no longer
send from that address. If the draft has no address chosen, `from` is
the default, with `"default": true`. Change it with `from_address_id`.

An address can be limited to some of the team. A personal token can
only choose an address its person may send from; any other returns
`422`. A team token can choose any address. A draft from a limited
address shows that address to everyone, and only the people allowed can
send it. Limiting an address is part of the Royal Jelly plan; on Jelly,
every address is open to everyone.

### Mailbox

`mailbox_id` is the mailbox a new message's conversation starts in
when it is sent. Without one, it is the default mailbox. Only people
and tokens that can see that mailbox can see the draft, plus whoever
started or last edited it. A reply's draft has `mailbox_id: null`: its
conversation is already in its mailboxes.

### The quoted message

`quoted` is the message a reply answers, as a quote. It is only filled
in until the draft is first saved. After that, the draft's body is the
whole reply and `quoted` is `""`.

### Pictures in the text

To put a picture in the text, upload it with `POST /api/uploads`, then
add it to the body using the `url` it returns:

```html
<p><img src="/uploads/9f86d081884c7d65…" alt="shot.png" class="jam-inline-image"></p>
```

`inline_images` maps each `/uploads/…` address in the body to a signed
link you can use to display it. Keep `/uploads/…` in the body when you
save; the signed links expire.

### Waiting to be sent

When a draft is waiting to be sent, `scheduled_for` is when it will go
and `scheduled_by` is who sent it. While it waits, it cannot be changed:
`PATCH`, file changes, `send` and `DELETE` all return `423`. Cancel the
send with `DELETE /api/drafts/{id}/send` to edit it again.

### `POST /api/conversations/{id}/draft_reply`

Start a reply

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Starts a reply to the conversation. With a personal token, recipients,
from address and body are filled in as in the web app, so `body` is
optional. With a team token, `body` is required.

Without recipients, the reply goes to the conversation's `reply`
recipients (see `GET /api/conversations/{id}`). Set `reply_to` to a
message's key to answer that message instead.

A conversation can have only one draft. If it already has one, the
answer is `409` with that draft.

`client_key` makes it safe to retry the request. Send any string of 1
to 64 characters. A second request with the same key returns the draft
the first one made, with `200`. Only personal tokens can use
`client_key`. The `Idempotency-Key` header does the same for any
write and any token (see Retrying safely).

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `body` | string | The body, as HTML. Required with a team token. `html_body` also works. |
| `subject` | string |  |
| `reply_to` | string | The key of the message to reply to. Defaults to the newest. `message_id` also works. |
| `to` | recipients | An array of addresses, or one comma-separated string. |
| `cc` | recipients |  |
| `bcc` | recipients |  |
| `client_key` | string | 1 to 64 characters, personal tokens only. Repeating a request with the same key returns the same draft. |

Example body:

```json
{
  "body": "<p>Thanks Jane, a new one is on its way.</p>",
  "client_key": "0F8E2C1A-7D3B-4C2E-9A61-5B0E8C7F1D24"
}
```

Answers:

- `201`: The new draft.
- `200`: The draft already made with this `client_key`.
- `409`: The conversation already has a draft. The response contains it.
- `404`: No such conversation, or not one this caller can see.
- `422`: A recipient that cannot be read, a `client_key` in use or from a team token, or no `body` from a team token.

Example `201`: the shared example `draft`, under Shared examples at the end.

Example `200`: the shared example `draft`, under Shared examples at the end.

Example `409`: the shared example `draft`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `POST /api/drafts`

Start a new message or forward

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Starts a new message. A team token must send `to` and `body`.

To forward a message, set `forward` to its key. The draft includes the
original message and its attachments, and does not need `to`.

Set `mailbox_id` to start the conversation in a mailbox other than the
default. It must be one the caller can use, or the answer is `422`.

`client_key` works as it does for `draft_reply`.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `to` | recipients | An array of addresses, or one comma-separated string. |
| `cc` | recipients |  |
| `bcc` | recipients |  |
| `subject` | string |  |
| `body` | string | The body, as HTML. Required with a team token. `html_body` also works. |
| `from_address_id` | integer | An address id from `GET /api/me`. `from`, the address itself, also works. |
| `mailbox_id` | integer | The mailbox the conversation starts in once sent. Defaults to the default mailbox. It must be one the caller can use. |
| `forward` | string | The key of a message to forward. |
| `client_key` | string | As on `draft_reply`. |

Example body:

```json
{
  "to": [
    "Jane Customer <jane@customer.test>"
  ],
  "subject": "About your order",
  "body": "<p>Hello Jane</p>",
  "from_address_id": 1
}
```

Answers:

- `201`: The new draft.
- `200`: The draft already made with this `client_key`.
- `422`: No `to` or `body` from a team token, a recipient that cannot be read, an address nobody may send from, or a mailbox the caller cannot use.

Example `201`: the shared example `draft`, under Shared examples at the end.

Example `200`: the shared example `draft`, under Shared examples at the end.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `GET /api/drafts/{id}`

Get a draft

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Returns a draft. Reading a draft does not hold it, so the answer has no
`lock_token`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such draft, or not visible to this token.

Example `200`: the shared example `draft`, under Shared examples at the end.


### `PATCH /api/drafts/{id}`

Save a draft

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Saves a draft. Fields you leave out are not changed.

With a personal token, saving holds the draft and returns a new
`lock_token`. If someone else holds it, the answer is `423`. With a team
token, the answer is `423` if anyone holds it.

Send `if_updated_at` with the `updated_at` you last saw. If the draft
has changed since then, the answer is `409 stale` with the current
draft, and nothing is saved.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `to` | recipients | An array of addresses, or one comma-separated string. |
| `cc` | recipients |  |
| `bcc` | recipients |  |
| `subject` | string |  |
| `body` | string | The body, as HTML. `html_body` also works. |
| `from_address_id` | integer | An address id this person can send from. |
| `mailbox_id` | integer | As on `POST /api/drafts`. Only for a new message or a forward. |
| `if_updated_at` | date-time | The draft's `updated_at` when you last saw it. If it has changed since, the answer is `409 stale`. |

Example body:

```json
{
  "body": "<p>Hello</p>",
  "if_updated_at": "2026-03-04T09:12:00Z"
}
```

Answers:

- `200`: The draft. A personal token gets a new `lock_token`.
- `404`: No such draft. It may have been sent or discarded.
- `409`: The draft changed after `if_updated_at`. Nothing was saved. The response contains the current draft.
- `422`: A recipient that cannot be read, an address this person may not send from, a mailbox the caller cannot use or a `mailbox_id` on a reply, or an `if_updated_at` that is not a time.
- `423`: Someone else is editing the draft. The response says who.

Example `200`: the shared example `draft`, under Shared examples at the end.

Example `409`:

```json
{
  "error": "stale",
  "message": "This draft has changed since you last saw it",
  "draft": {
    "id": 41,
    "html_body": "<p>Bob’s reply.</p>",
    "updated_at": "2026-03-04T09:15:41Z",
    "locked": false
  },
  "quoted": ""
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `DELETE /api/drafts/{id}`

Discard a draft

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Discards a draft and its files. Returns `423` if someone else is
editing it.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `lock_token` | string | The `lock_token` you got when you started holding the draft. |

Example body:

```json
{
  "lock_token": "2BxI…"
}
```

Answers:

- `204`: Discarded.
- `404`: No such draft.
- `423`: Someone else is editing the draft. The response says who.

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `POST /api/drafts/{id}/heartbeat`

Keep holding a draft

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Keeps holding a draft without saving it. Send it every minute while a
compose screen is open but not saving. Returns `409` if you do not hold
the draft.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: You still hold the draft.
- `404`: No such draft.
- `409`: You do not hold the draft. Save it with `PATCH` first.
- `423`: Someone else is editing the draft. The response says who.

Example `409`:

```json
{
  "error": "not_editing",
  "message": "You are not editing this draft"
}
```

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `DELETE /api/drafts/{id}/lock`

Release a draft

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Releases a draft you hold, so someone else can edit it straight away.
Send it when the compose screen closes. Returns `409 not_editing` if the
`lock_token` is not the current one.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `lock_token` | string, required | The `lock_token` you got when you started holding the draft. |

Example body:

```json
{
  "lock_token": "2BxI…"
}
```

Answers:

- `200`: The draft, now not held by anyone.
- `404`: No such draft.
- `409`: The `lock_token` is not the current one.
- `423`: Someone else is editing the draft. The response says who.

Example `200`:

```json
{
  "draft": {
    "id": 41,
    "locked": false,
    "editor": null,
    "…": "…"
  },
  "quoted": ""
}
```

Example `409`:

```json
{
  "error": "not_editing",
  "message": "You are not editing this draft"
}
```

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `POST /api/drafts/{id}/attachments`

Add a file to a draft

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Adds a file to a draft, sent as `multipart/form-data` in a field named
`file`. Or send a JSON body, `{"file_id": "…"}`, naming a file you
uploaded with `POST /api/files`. You must hold the draft.

A file that is already attached returns `409`. A file that another
person or a team token uploaded returns `404`.

A draft can have up to 20 files, totalling 10 MB. Going over returns
`422` or `413`. An empty file returns `422`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (multipart/form-data):

| Field | Type | |
|---|---|---|
| `file` | binary | One file. Or send a JSON body, `{"file_id": "…"}`, naming a file from `POST /files`. |

Answers:

- `201`: The added file.
- `404`: No such draft, or a `file_id` this person did not upload or that has expired.
- `409`: A file from `POST /files` is already attached. Nothing was saved.
- `413`: The draft's files would be over 10 MB in total.
- `422`: No file, an empty one, or a twenty-first.
- `423`: Someone else is editing the draft. The response says who.

Example `201`:

```json
{
  "attachment": {
    "id": 5,
    "filename": "invoice.pdf",
    "content_type": "application/pdf",
    "byte_size": 88213,
    "url": "https://jelly.acme.test/api/draft_attachments/5/download?expires=1772615100&signature=…"
  }
}
```

Example `409`:

```json
{
  "error": "already_attached",
  "message": "That file is already attached to something. Upload it again to attach it twice"
}
```

Example `413`:

```json
{
  "error": "too_large",
  "message": "Attachments must total under 10 MB"
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `DELETE /api/drafts/{id}/attachments/{attachment}`

Remove a file from a draft

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Removes a file from a draft. You must hold the draft.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `attachment` | path | string, required | The attachment's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Removed.
- `404`: No such draft or file.
- `423`: Someone else is editing the draft. The response says who.

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `POST /api/drafts/{id}/saved_reply_files`

Copy a saved reply's files onto a draft

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Copies a saved reply's files onto the draft. The answer is the draft's
full list of files. Files already on the draft are not added twice. You
must hold the draft, and the draft's file limits apply.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `saved_reply_id` | integer, required | A saved reply id from `GET /api/saved_replies`. |

Example body:

```json
{
  "saved_reply_id": 4
}
```

Answers:

- `200`: All of the draft's files.
- `404`: No such draft.
- `413`: The files would be over 10 MB in total. None were added.
- `422`: No such saved reply, or they would take the draft past twenty files. None was added.
- `423`: Someone else is editing the draft. The response says who.

Example `200`:

```json
{
  "attachments": [
    {
      "id": 31,
      "filename": "invoice.pdf",
      "content_type": "application/pdf",
      "byte_size": 88213,
      "url": "https://…"
    },
    {
      "id": 32,
      "filename": "returns-form.pdf",
      "content_type": "application/pdf",
      "byte_size": 48213,
      "url": "https://…"
    }
  ]
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


### `POST /api/uploads`

Upload a picture

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Uploads a picture for the text of a draft or note, sent as
`multipart/form-data` in a field named `file`, up to 10 MB.

Put `url` in the body. Use `display_url` to show the picture until
`links_expire_at`; do not save it in the body. A file that is not a
picture returns `415`; add it as an attachment instead.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (multipart/form-data):

| Field | Type | |
|---|---|---|
| `file` | binary, required | One picture, up to 10 MB. |

Answers:

- `201`: The `url` to put in the body, and a `display_url` to show it.
- `413`: Over 10 MB.
- `415`: Not a picture. Add it to the draft as a file instead.
- `400`: No file.

Example `201`:

```json
{
  "url": "/uploads/9f86d081884c7d65",
  "name": "shot.png",
  "type": "image/png",
  "size": 48213,
  "display_url": "https://jelly.acme.test/api/uploads/9f86d081884c7d65/download?expires=1772615100&signature=…",
  "links_expire_at": "2026-03-04T09:05:00Z"
}
```

Example `413`:

```json
{
  "error": "too_large",
  "message": "That picture is over 10 MB"
}
```

Example `415`:

```json
{
  "error": "not_an_image",
  "message": "That isn't a picture. Send it as an attachment instead"
}
```

Example `400`:

```json
{
  "error": "unprocessable",
  "message": "file is required"
}
```


## Sending

Sending a draft, and cancelling a send. Only a personal token can send.
A team token gets `403 needs_person`.

### `POST /api/drafts/{id}/send`

Send a draft

Accepts: **Personal token** · Scope: `send` · Tier: **App**

Sends a draft. Include the `lock_token` from holding the draft; without
it, the answer is `423`.

Without `at`, the message waits for the team's undo window
(`send_delay_seconds` in `GET /api/me`) and then goes. With `at`, it
goes at that time.

While the message waits, the answer has the `draft`, `scheduled_for`
and `undo_until`. Once it has gone, the answer has the `message`.
Sending a draft that has already gone returns `404`, so a retry never
sends twice.

`if_updated_at` works as it does for `PATCH /api/drafts/{id}`. A missing
recipient, subject or body returns `422` with a message to show the
person. So does a `from` address the person may not send from. The
draft is not changed.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `lock_token` | string, required | The `lock_token` you got when you started holding the draft. |
| `at` | date-time | When to send. Without it, the draft is sent after the undo window. |
| `archive` | boolean | Archive the conversation after sending. A draft that starts a conversation, a new message or a forward, is always archived once sent, whatever this says. |
| `if_updated_at` | date-time | As for `PATCH /api/drafts/{id}`. |

Example body:

```json
{
  "lock_token": "2BxI…",
  "at": "2026-03-05T09:00:00Z",
  "archive": false,
  "if_updated_at": "2026-03-05T08:59:12Z"
}
```

Answers:

- `200`: The draft while it waits to be sent, or the message once sent.
- `404`: No such draft, or it has already been sent.
- `409`: The draft changed after `if_updated_at`. Nothing was sent.
- `422`: No recipient, an address that will not parse, no subject, or no words. `errors` has the web's own sentence.
- `423`: Missing or wrong `lock_token`, or the draft is already waiting to be sent.

Example `200`:

```json
{
  "draft": {
    "id": 41,
    "locked": false,
    "…": "…"
  },
  "message": null,
  "scheduled_for": "2026-03-05T12:00:05Z",
  "undo_until": "2026-03-05T12:00:05Z"
}
```

Example `409`:

```json
{
  "error": "stale",
  "message": "This draft has changed since you last saw it",
  "draft": {
    "id": 41,
    "…": "…"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/drafts/{id}/send`

Cancel a send

Accepts: **Personal token** · Scope: `send` · Tier: **App**

Cancels a send, until `undo_until`. The draft comes back held by the
caller, with a new `lock_token`.

Send `{"keep_editing": false}` to leave the draft not held by anyone
instead.

Returns `409 too_late` if the message is being sent at that moment, and
`404` once it has gone.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `keep_editing` | boolean | `false`: leave the draft not held by anyone. By default, the caller holds it. |

Example body:

```json
{
  "keep_editing": false
}
```

Answers:

- `200`: The draft.
- `404`: No such draft, or it has been sent.
- `409`: Too late: it is being sent.
- `423`: Someone else is editing the draft. The response says who.

Example `200`: the shared example `draft`, under Shared examples at the end.

Example `409`:

```json
{
  "error": "too_late",
  "message": "Too late — it had already gone"
}
```

Example `423`:

```json
{
  "error": "locked",
  "message": "Sarah Chen is writing this draft right now",
  "editor": {
    "type": "member",
    "id": 3,
    "name": "Sarah Chen"
  }
}
```


## Live

Live updates, and who is looking at a conversation. Both need a
personal token.

### `GET /api/events`

Stream changes

Accepts: **Personal token** · Scope: `read` · Tier: **App**

A stream of Server-Sent Events that stays open. Each change sends an
`event: change` whose data is the changed conversation's id. Empty data
means the lists have changed. A `: ping` comment comes every 25 seconds.
When you get an event, fetch what changed.

The stream ends with a `close` event when the token stops working:

```
event: close
data: expired
```

- `expired`: the access token has expired or been refreshed. Reconnect
  with the new token.
- `revoked`: the person has been signed out. Refreshing will fail.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: Server-Sent Events, until the token expires.

Example `200`:

```
retry: 3000

event: change
data: broken-widget-k3f9zq

: ping

event: close
data: expired
```


### `POST /api/conversations/{id}/presence`

Set presence

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Says what this person is doing in a conversation, so others can see it:
`viewing`, `replying`, or `commenting`, which is writing a note. Send it
every 15 seconds while the conversation is open; a status not repeated
for 30 seconds is dropped. Send `gone` when the person leaves.

Others see the change on `/api/events`, and in the thread's `presence`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `status` | string: `viewing` \| `replying` \| `commenting` \| `gone`, required | `commenting` is writing a note. |

Example body:

```json
{
  "status": "replying"
}
```

Answers:

- `204`: Saved.
- `404`: No such conversation, or not one this caller can see.


## Devices

Phones and Macs registered for notifications. These need a personal token.

### `POST /api/devices`

Register a device

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Registers a phone or a Mac for notifications. The device then gets the
same notifications the person gets in the web app. Send `platform` as
`ios` from the phone app and `macos` from the Mac app.

The answer has the device's `id`, and a `key_id` and `key` used to
encrypt its notifications. Notifications are encrypted with AES-256-GCM,
so Jelly's relay and Apple cannot read them.

Registering the same device again returns the same device. Registering a
device that another sign-in registered replaces that registration.

A device is removed when the person signs out, their sign-in is revoked,
or they leave the team. Check `push` in `GET /api/me` first: when it is
`false`, no notifications are sent.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `platform` | string: `ios` \| `macos`, required | `ios` for the phone app, `macos` for the Mac app. |
| `token` | string, required | The APNs device token, in hex. |
| `environment` | string: `sandbox` \| `production`, required |  |
| `bundle_id` | string, required |  |
| `app_version` | string |  |

Example body:

```json
{
  "platform": "ios",
  "token": "a1b2c3…",
  "environment": "production",
  "bundle_id": "com.letsjelly.jelly",
  "app_version": "1.4"
}
```

Answers:

- `201`: The device, and the key its notifications are encrypted with.
- `422`: Not a device: `errors` says what is missing.

Example `201`:

```json
{
  "id": 7,
  "key_id": "q0Zc3n1V8kS2m-Yd4TQxRw",
  "key": "c2VjcmV0IGtleSBvZiAzMiByYW5kb20gYnl0ZXMhIQ=="
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/devices/{id}`

Remove a device

Accepts: **Personal token** · Scope: `write` · Tier: **App**

Removes a device. Returns `404` if the device is not this person's.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The device's id, as registering it answered. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Removed.
- `404`: No such device for this person.


## Labels

The team's labels. Anyone can read them. Creating, changing and deleting
labels needs **change team settings**. To put a label on a conversation,
use `POST /api/conversations/{id}/labels`.

`colour` is a colour from Jelly's palette.

### `GET /api/labels`

List labels

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists every label, by name.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "labels": [
    {
      "id": "billing",
      "name": "Billing",
      "colour": "lemon",
      "created_at": "2026-01-02T10:00:00Z"
    }
  ],
  "next_cursor": null
}
```


### `POST /api/labels`

Create a label

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Creates a label. If a label with that name exists, the answer is `200`
with that label. Without a valid `colour`, a colour is chosen for you.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `name` | string, required |  |
| `colour` | string | A colour from the palette. |

Example body:

```json
{
  "name": "Billing",
  "colour": "lemon"
}
```

Answers:

- `201`: Created
- `200`: A label with that name already exists.
- `422`: No `name`.

Example `201`:

```json
{
  "label": {
    "id": "billing",
    "name": "Billing",
    "colour": "lemon",
    "created_at": "2026-03-04T09:00:00Z"
  }
}
```

Example `200`:

```json
{
  "label": {
    "id": "billing",
    "name": "Billing",
    "colour": "lemon",
    "created_at": "2026-01-02T10:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `PATCH /api/labels/{slug}`

Update a label

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Renames a label or changes its colour. The label's id does not change.

| Parameter | In | Type | |
|---|---|---|---|
| `slug` | path | string, required | The label's id: its slug, which does not change when the label is renamed. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `name` | string |  |
| `colour` | string | A colour from the palette. |

Example body:

```json
{
  "name": "Invoices"
}
```

Answers:

- `200`: OK
- `404`: No such label.
- `422`: A colour not in the palette, or a name another label has.

Example `200`:

```json
{
  "label": {
    "id": "billing",
    "name": "Invoices",
    "colour": "lemon",
    "created_at": "2026-01-02T10:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/labels/{slug}`

Delete a label

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Deletes a label and removes it from every conversation.

| Parameter | In | Type | |
|---|---|---|---|
| `slug` | path | string, required | The label's id: its slug, which does not change when the label is renamed. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Deleted, and removed from every conversation.
- `404`: No such label.


## Team

The team's members and mailboxes.

### `GET /api/members`

List members

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists every active member.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "members": [
    {
      "id": 3,
      "name": "Sarah Chen",
      "email": "sarah@acme.test",
      "role": "admin",
      "active": true,
      "avatar": {
        "url": null,
        "initials": "SC",
        "tint": {
          "light": {
            "background": "#5e35b1",
            "text": "#ffffff"
          },
          "dark": {
            "background": "#5e35b1",
            "text": "#ffffff"
          }
        }
      }
    }
  ],
  "next_cursor": null
}
```


### `GET /api/mailboxes`

List mailboxes

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists the mailboxes the caller can see. For a team token, these are the
mailboxes it was given.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "mailboxes": [
    {
      "id": 1,
      "name": "Support",
      "default": true
    },
    {
      "id": 2,
      "name": "Sales",
      "default": false
    }
  ],
  "next_cursor": null
}
```


### `GET /api/mailboxes/{id}/members`

List a mailbox's members

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists the active members of a mailbox. Every member is in the default
mailbox.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The mailbox's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such mailbox, or not one this caller can see.

Example `200`:

```json
{
  "members": [
    {
      "id": 3,
      "name": "Sarah Chen",
      "email": "sarah@acme.test",
      "role": "admin",
      "active": true
    }
  ],
  "next_cursor": null
}
```


## Saved replies

The team's saved replies. They are written in the web app; the API can
read them and copy their files to a draft.

### `GET /api/saved_replies`

List saved replies

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists every saved reply, by name. To use one in a draft:

1. Insert `html_body` into the draft's body at the cursor.
2. If the draft has no subject, use the reply's `subject`.
3. Save the draft with `PATCH /api/drafts/{id}`.
4. If the reply has `attachments`, copy them with
   `POST /api/drafts/{id}/saved_reply_files`.

`inline_images` maps each picture in `html_body` to a link you can use
to display it. The links expire after five minutes, so fetch the list
again each time you show it.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "saved_replies": [
    {
      "id": 4,
      "name": "Order tracking",
      "subject": "Your order",
      "html_body": "<p>Your tracking link is below.</p><p><img src=\"/uploads/9f86d081\" alt=\"map.png\" class=\"jam-inline-image\"></p>",
      "text_body": "Your tracking link is below.",
      "inline_images": {
        "/uploads/9f86d081": "https://jelly.acme.test/api/uploads/9f86d081/download?expires=1772615100&signature=…"
      },
      "attachments": [
        {
          "id": 7,
          "filename": "returns-form.pdf",
          "content_type": "application/pdf",
          "byte_size": 48213,
          "url": "https://jelly.acme.test/api/saved_reply_files/7/download?expires=1772615100&signature=…"
        }
      ],
      "created_at": "2026-01-02T10:00:00Z",
      "updated_at": "2026-02-02T10:00:00Z"
    }
  ],
  "next_cursor": null
}
```


## Contacts

The people the team writes to.

A contact's `fields` holds its values for the team's contact fields
(see Contact fields), by key. A field with no value is not in `fields`.

`external_id` is your own id for the contact, such as the id of the
customer in your system. Only one contact can have a given
`external_id`. A contact from these routes always has the key, `null`
until you set it. A conversation's contact has it only when it is set.

### `GET /api/contacts`

Find a contact, or list contacts by a field

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

With `email`, finds the contact with that address.

With `external_id`, finds the contact with that external id.

With `field`, lists the contacts that have a value for that field,
oldest first, a page at a time. Add `value` to list only the contacts
with that value: `?field=plan&value=pro`. Text is matched whatever its
capitals. A date is `YYYY-MM-DD`, and a boolean is `true` or `false`.

```json
{"contacts": [{"id": "jane-customer", "email": "jane@customer.test", "name": "Jane Customer", "fields": {"plan": "pro"}, "…": "…"}], "next_cursor": null}
```

| Parameter | In | Type | |
|---|---|---|---|
| `email` | query | string | Their email address. Finds one contact. |
| `external_id` | query | string | Their external id. Finds one contact. |
| `field` | query | string | A contact field's key. Lists the contacts with a value for it, instead of finding one. |
| `value` | query | string | With `field`, only contacts whose value is this one. |
| `limit` | query | integer | Results per page, 1 to 100. The default is 50. |
| `cursor` | query | string | The `next_cursor` from the previous page. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: With `email`, the contact under `contact`. With `field`, a page of contacts under `contacts`, and `next_cursor`.
- `404`: No contact with that address or external id.
- `422`: None of `email`, `external_id` and `field`, a `field` that is not a contact field, or a `value` that field could not hold.

Example `200`:

```json
{
  "contact": {
    "id": "jane-customer",
    "email": "jane@customer.test",
    "name": "Jane Customer",
    "note": "Prefers phone",
    "links": {
      "CRM": "https://crm.acme.test/jane"
    },
    "fields": {
      "plan": "pro",
      "renews_on": "2027-01-01"
    },
    "blocked": false,
    "never_spam": false,
    "created_at": "2026-01-02T10:00:00Z",
    "updated_at": "2026-03-04T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `GET /api/contacts/suggest`

Suggest addresses for a recipient field

Accepts: **Personal token** · Scope: `read` · Tier: **App**

Suggests addresses for someone typing a recipient. Use it to fill in a
`to`, `cc` or `bcc` field. Jelly's web composer suggests the same
addresses in the same order.

`q` is what has been typed. A contact is suggested when `q` is part of
their name or of one of their addresses, whatever its capitals. A `q`
under 2 characters returns an empty list.

Each suggestion is one address, so a contact with two addresses can be
two suggestions. Put `value` in the field: it is the name and the
address, quoted where needed.

Matches at the start of a word or an address come first. Then come the
contacts you last had mail with, then the rest by name. Blocked
contacts are never suggested.

| Parameter | In | Type | |
|---|---|---|---|
| `q` | query | string | What has been typed: part of a name or an address. Under 2 characters, the list is empty. |
| `limit` | query | integer | Suggestions at most, 1 to 20. The default is 8. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `422`: A `limit` that is not 1 to 20.

Example `200`:

```json
{
  "suggestions": [
    {
      "contact_id": "ann-lee",
      "name": "Ann Lee",
      "email": "ann@lee.test",
      "value": "Ann Lee <ann@lee.test>"
    },
    {
      "contact_id": "ann-lee",
      "name": "Ann Lee",
      "email": "ann.lee@home.test",
      "value": "Ann Lee <ann.lee@home.test>"
    }
  ],
  "next_cursor": null
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `POST /api/contacts`

Create or update a contact

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Creates a contact, or updates the one with that email address. Returns
`201` for a new contact and `200` for an existing one. Fields you leave
out are not changed.

`fields` sets field values by key: `{"fields": {"plan": "pro", "seats":
12}}`. Keys you leave out keep their values, and `null` clears one. A key
that is not a contact field, or a value of the wrong type, is a `422`
that names the field, and nothing is changed.

`external_id` sets the contact's external id. `null` or `""` clears it.
If another contact has it, the answer is `409` and nothing is changed.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `email` | string, required |  |
| `name` | string |  |
| `note` | string |  |
| `links` | object | Links, as name and URL pairs. |
| `fields` | object | Field values, by the field's key. `null` clears one. |
| `external_id` | string, or null | Your own id for this contact, up to 255 characters. `null` or `""` clears it. |
| `blocked` | boolean |  |
| `never_spam` | boolean |  |

Example body:

```json
{
  "email": "jane@customer.test",
  "name": "Jane Customer",
  "note": "Prefers phone",
  "links": {
    "Their site": "https://customer.test"
  },
  "fields": {
    "plan": "pro",
    "seats": 12
  }
}
```

Answers:

- `201`: A new contact.
- `200`: The updated contact.
- `409`: Another contact has this `external_id`. Nothing is changed.
- `422`: No `email`, or not an address. A key in `fields` that is not a contact field, or a value of the wrong type. Nothing is changed.

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Contact fields

The facts the team keeps about each contact, such as a plan, an account
id or a renewal date. A field has a `key`, a `label` and a `type`.
Anyone can read the fields. Creating, changing and deleting them needs
**change team settings**. To set a contact's value, send `fields` to
`POST /api/contacts`.

The `key` is the field's id. It is how `fields` names the field on a
contact, and how search names it (`field:plan=pro`). It cannot change.
The `label` is what people read, and can change.

The types, and the values they hold:

| Type | Value |
|---|---|
| `text` | A string. |
| `number` | A number. Store money as a whole number of cents. |
| `date` | A string, `YYYY-MM-DD`. |
| `boolean` | `true` or `false`. |
| `url` | An `http` or `https` URL. |
| `choice` | One of the field's `options`. |

### `GET /api/contact_fields`

List contact fields

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists every contact field, in the order the contact pane shows them.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "contact_fields": [
    {
      "key": "plan",
      "label": "Plan",
      "type": "choice",
      "options": [
        "free",
        "pro"
      ],
      "position": 1,
      "created_at": "2026-01-02T10:00:00Z",
      "updated_at": "2026-01-02T10:00:00Z"
    },
    {
      "key": "renews_on",
      "label": "Renews on",
      "type": "date",
      "position": 2,
      "created_at": "2026-01-02T10:00:00Z",
      "updated_at": "2026-01-02T10:00:00Z"
    }
  ],
  "next_cursor": null
}
```


### `POST /api/contact_fields`

Create a contact field

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Creates a field, after the others. The `type` cannot change later. A
`choice` needs `options`.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `key` | string, required | Lowercase letters, digits and underscores, starting with a letter. It cannot change. |
| `label` | string, required |  |
| `type` | string: `text` \| `number` \| `date` \| `boolean` \| `url` \| `choice`, required | It cannot change. |
| `options` | array of string | The choices, for a `choice`. |

Example body:

```json
{
  "key": "plan",
  "label": "Plan",
  "type": "choice",
  "options": [
    "free",
    "pro"
  ]
}
```

Answers:

- `201`: Created
- `422`: A missing or malformed `key`, a key another field has, no `label`, an unknown `type`, or a `choice` without options.

Example `201`:

```json
{
  "contact_field": {
    "key": "plan",
    "label": "Plan",
    "type": "choice",
    "options": [
      "free",
      "pro"
    ],
    "position": 3,
    "created_at": "2026-03-04T09:00:00Z",
    "updated_at": "2026-03-04T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `PATCH /api/contact_fields/{key}`

Update a contact field

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Changes a field's `label`, its `position`, or a `choice`'s `options`.
Send every option you want to keep. An option a contact holds cannot be
left out: set those contacts to another value first.

| Parameter | In | Type | |
|---|---|---|---|
| `key` | path | string, required | Identifies the contact_field. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `label` | string |  |
| `position` | integer | Where it is shown, from 1. |
| `options` | array of string | A `choice`'s options, all of them. |

Example body:

```json
{
  "label": "Plan name",
  "position": 1
}
```

Answers:

- `200`: OK
- `404`: No such field.
- `422`: An empty `label`, a changed `key` or `type`, or `options` that leave out one a contact holds.

Example `200`:

```json
{
  "contact_field": {
    "key": "plan",
    "label": "Plan name",
    "type": "choice",
    "options": [
      "free",
      "pro"
    ],
    "position": 1,
    "created_at": "2026-01-02T10:00:00Z",
    "updated_at": "2026-03-04T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/contact_fields/{key}`

Delete a contact field

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Deletes a field and every contact's value for it. The key can then be
used for a new field.

| Parameter | In | Type | |
|---|---|---|---|
| `key` | path | string, required | Identifies the contact_field. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Deleted, with every contact's value for it.
- `404`: No such field.


## Contact cards

A contact card is what one of your own systems knows about a
conversation's contact, drawn beside the conversation in Jelly. Jelly
asks each card source with a signed `POST` and draws the JSON it
answers. What a source is sent and must answer is in
[Contact cards](/manual/advanced/contact-cards#the-request) in the
manual.

Contact cards are part of the Royal Jelly plan. On the Jelly plan, a
team can list, turn off and remove its sources. Anything else here
answers `403` with the error `plan`.

### `GET /api/card_sources`

List card sources

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Lists the team's card sources, oldest first. Needs **change team
settings**. Each source carries its signing `secret`.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "card_sources": [
    {
      "id": 2,
      "name": "Acme billing",
      "url": "https://billing.acme.test/jelly/card",
      "secret": "3f6c…",
      "timeout_seconds": 3,
      "active": true,
      "created_by": {
        "type": "member",
        "id": 3,
        "name": "Sarah Chen",
        "email": "sarah@acme.test",
        "role": "admin",
        "active": true
      },
      "created_at": "2026-03-01T09:00:00Z",
      "updated_at": "2026-03-01T09:00:00Z"
    }
  ],
  "next_cursor": null
}
```


### `POST /api/card_sources`

Add a card source

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Adds a card source. Needs **change team settings**. Jelly makes the
signing secret; it is in the answer.

`url` must be `https` and on the public internet. A source on a private
network, or on this machine, is refused, both here and each time Jelly
asks it.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `name` | string, required | Up to 80 characters. |
| `url` | string, required | An https URL on the public internet. |
| `timeout_seconds` | integer | 1, 2 or 3. The default is 3. |
| `active` | boolean | The default is `true`. |

Example body:

```json
{
  "name": "Acme billing",
  "url": "https://billing.acme.test/jelly/card"
}
```

Answers:

- `201`: Created
- `403`: Contact cards are not on this team's plan.
- `422`: No `name`, or a `url` that is not https or not on the public internet.

Example `201`:

```json
{
  "card_source": {
    "id": 2,
    "name": "Acme billing",
    "url": "https://billing.acme.test/jelly/card",
    "secret": "3f6c…",
    "timeout_seconds": 3,
    "active": true,
    "created_by": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen",
      "email": "sarah@acme.test",
      "role": "admin",
      "active": true
    },
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```

Example `403`:

```json
{
  "error": "plan",
  "message": "Contact cards are not on your team's plan. The Royal plan includes them."
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `PATCH /api/card_sources/{id}`

Update a card source

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Changes a card source. Send only the fields to change. Needs **change
team settings**. Changing `url` does not change the secret.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the card_source. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `name` | string |  |
| `url` | string |  |
| `timeout_seconds` | integer |  |
| `active` | boolean |  |

Example body:

```json
{
  "active": false
}
```

Answers:

- `200`: OK
- `403`: Contact cards are not on this team's plan. Turning a source off is still allowed.
- `404`: No such card source.
- `422`: A field with the wrong value.

Example `200`:

```json
{
  "card_source": {
    "id": 2,
    "name": "Acme billing",
    "url": "https://billing.acme.test/jelly/card",
    "secret": "3f6c…",
    "timeout_seconds": 3,
    "active": true,
    "created_by": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen",
      "email": "sarah@acme.test",
      "role": "admin",
      "active": true
    },
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/card_sources/{id}`

Remove a card source

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Removes a card source. Needs **change team settings**.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the card_source. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Removed.
- `404`: No such card source.


### `GET /api/conversations/{id}/cards`

Get a conversation's contact cards

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Asks every active card source about the conversation's contact, and
answers with one item per source, in the order they were added. An
item has `card` and `error`: `card` is the source's answer, as Jelly
read it, or `null` with the reason in `error`. A source that answers
`404` has nothing about the contact, and has no item.

The sources are asked at the same time, so this takes as long as the
slowest source, up to three seconds. Answers are kept for the source's
`ttl_seconds`, so asking again soon after may not ask the source.

A conversation with no contact answers an empty `cards` list. A card
is shown to anyone who can see the conversation.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The conversation's id: its slug, as every list hands it out. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `403`: Contact cards are not on this team's plan.
- `404`: No such conversation, or not one this caller can see.

Example `200`:

```json
{
  "cards": [
    {
      "source": {
        "id": 2,
        "name": "Acme billing"
      },
      "card": {
        "title": "Acme billing",
        "rows": [
          {
            "label": "Plan",
            "value": "Pro",
            "href": "https://admin.acme.test/c/42"
          }
        ],
        "badges": [
          {
            "label": "Past due",
            "tone": "bad"
          }
        ],
        "links": [
          {
            "label": "Open in admin",
            "href": "https://admin.acme.test/c/42"
          }
        ],
        "ttl_seconds": 300
      },
      "error": null,
      "fetched_at": "2026-03-04T09:20:00Z"
    },
    {
      "source": {
        "id": 3,
        "name": "CRM"
      },
      "card": null,
      "error": "The source took longer than 3 seconds to answer",
      "fetched_at": null
    }
  ]
}
```


## Conversation fields

The facts the team keeps about each conversation, such as the order
it is about, the customer's plan or the app version they wrote from. A
field has a `key`, a `label` and a `type`, the same types as contact
fields (see Contact fields). Anyone can read the fields. Creating,
changing and deleting them needs **change team settings**.

To set a conversation's values, send `fields` to
`PATCH /api/conversations/{id}`, or with the message to
`POST /api/messages`. Rules can match on them and set them.

The `key` is the field's id. It is how `fields` names the field, how a
list filters by it (`field.order=A-1234`) and how search finds it
(`field:order=A-1234`). It cannot change. A conversation field and a
contact field can have the same key; search then matches either.

### `GET /api/conversation_fields`

List conversation fields

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Lists every conversation field, in order.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "conversation_fields": [
    {
      "key": "plan",
      "label": "Plan",
      "type": "choice",
      "options": [
        "free",
        "enterprise"
      ],
      "position": 1,
      "created_at": "2026-01-02T10:00:00Z",
      "updated_at": "2026-01-02T10:00:00Z"
    },
    {
      "key": "order",
      "label": "Order",
      "type": "text",
      "position": 2,
      "created_at": "2026-01-02T10:00:00Z",
      "updated_at": "2026-01-02T10:00:00Z"
    }
  ],
  "next_cursor": null
}
```


### `POST /api/conversation_fields`

Create a conversation field

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Creates a field, after the others. The `type` cannot change later. A
`choice` needs `options`.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `key` | string, required | Lowercase letters, digits and underscores, starting with a letter. It cannot change. |
| `label` | string, required |  |
| `type` | string: `text` \| `number` \| `date` \| `boolean` \| `url` \| `choice`, required | It cannot change. |
| `options` | array of string | The choices, for a `choice`. |

Example body:

```json
{
  "key": "order",
  "label": "Order",
  "type": "text"
}
```

Answers:

- `201`: Created
- `422`: A missing or malformed `key`, a key another field has, no `label`, an unknown `type`, or a `choice` without options.

Example `201`:

```json
{
  "conversation_field": {
    "key": "order",
    "label": "Order",
    "type": "text",
    "position": 3,
    "created_at": "2026-03-04T09:00:00Z",
    "updated_at": "2026-03-04T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `PATCH /api/conversation_fields/{key}`

Update a conversation field

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Changes a field's `label`, its `position`, or a `choice`'s `options`.
Send every option you want to keep. An option a conversation holds
cannot be left out: set those conversations to another value first.

| Parameter | In | Type | |
|---|---|---|---|
| `key` | path | string, required | Identifies the conversation_field. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `label` | string |  |
| `position` | integer | Where it is shown, from 1. |
| `options` | array of string | A `choice`'s options, all of them. |

Example body:

```json
{
  "label": "Order number",
  "position": 1
}
```

Answers:

- `200`: OK
- `404`: No such field.
- `422`: An empty `label`, a changed `key` or `type`, or `options` that leave out one a conversation holds.

Example `200`:

```json
{
  "conversation_field": {
    "key": "order",
    "label": "Order number",
    "type": "text",
    "position": 1,
    "created_at": "2026-01-02T10:00:00Z",
    "updated_at": "2026-03-04T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/conversation_fields/{key}`

Delete a conversation field

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Deletes a field and every conversation's value for it. Rules that use
it stop matching. The key can then be used for a new field.

| Parameter | In | Type | |
|---|---|---|---|
| `key` | path | string, required | Identifies the conversation_field. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Deleted, with every conversation's value for it.
- `404`: No such field.


## Autoresponder

The team's default [autoresponder](/manual/advanced/autoresponder). It
answers every message that no rule sends a different autoresponse to.
Autoresponders sent by rules are not in the API.

### `GET /api/autoresponder`

Get the autoresponder

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Returns the default autoresponder.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "autoresponder": {
    "enabled": true,
    "message": "<p>Thanks, we'll be in touch.</p>",
    "first_message_only": true,
    "mark_conversation_as_read": false
  }
}
```


### `PATCH /api/autoresponder`

Update the autoresponder

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Changes the default autoresponder. Fields you leave out are not changed.

- `first_message_only`: answer only the first message in a conversation.
- `mark_conversation_as_read`: mark the conversation read after
  answering.
- `message`: HTML, or plain text.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `enabled` | boolean |  |
| `message` | string | HTML, or plain text. |
| `first_message_only` | boolean |  |
| `mark_conversation_as_read` | boolean |  |

Example body:

```json
{
  "enabled": true,
  "message": "<p>Thanks, we'll be in touch.</p>"
}
```

Answers:

- `200`: OK

Example `200`:

```json
{
  "autoresponder": {
    "enabled": true,
    "message": "<p>Thanks, we'll be in touch.</p>",
    "first_message_only": true,
    "mark_conversation_as_read": false
  }
}
```


## Changes

Everything that happens to the team's conversations, messages, notes
and contacts, in order. Use it to keep a copy of the team's mail up to
date, or to react to changes, without polling every list.

### `GET /api/changes`

List changes

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Any token. Changes to conversations the caller cannot see are left out.

Returns changes in the order they happened, oldest first. Each change
has a `seq`, a number that only grows.

To keep up:

1. Call it without `since`. Keep `latest_seq` from the response.
2. Call it again with `since` set to what you kept. Handle each change,
   then keep `next_cursor`.
3. Repeat step 2. If `has_more` is `true`, call again at once.

Add `wait=25` to step 2 to wait up to 25 seconds for a change when there
is none yet. The response comes as soon as something happens. This
replaces polling: a client that waits makes about one request every 25
seconds.

`next_cursor` can be past the last change in the response. The feed
leaves out changes the caller cannot see, and the cursor moves past
them too.

Changes are kept for 30 days. A `since` older than that gets
`410 cursor_expired`. So does a `since` that is not from this feed. Then
fetch what you need from the lists, such as
`GET /conversations?updated_since=`, and start again from `latest_seq`.

A change says what happened and to what, not what it is now. Fetch the
conversation, message, note or contact for its current state. Ids are
the ones the rest of the API uses. An id is `null` when the change has
none, or when the thing no longer exists. `actor` is who did it (see
[People](#description/people)). `summary` holds a few ids and values
that depend on the kind, listed below. It never holds anybody's words.

A team token sees changes in the mailboxes it was given, and changes
to contacts. A change that moves a conversation between mailboxes is
seen in both, so a token learns that a conversation left its mailboxes.

#### Kinds

| Kind | What happened |
| --- | --- |
| `conversation.created` | A conversation was opened: by mail arriving, a message sent, an insertion or a split. |
| `message.received` | A message arrived from outside. |
| `message.sent` | A message went out from the team. `summary.automatic` is true for the autoresponder's reply. |
| `message.forwarded` | A rule forwarded a message to an outside address. |
| `message.unwrapped` | A forwarded message was shown as the message inside it. |
| `message.rewrapped` | An unwrapped message was put back as it arrived. |
| `message.delivery_updated` | A sent message's delivery changed: delivered, bounced, complained. |
| `message.reported` | A member reported a message that shows wrongly. |
| `message.report_withdrawn` | A member withdrew a report. |
| `message.report_answered` | Support answered a report. |
| `message.report_closed` | A report was closed. |
| `note.added` | A note was added. |
| `note.updated` | A note was edited. |
| `note.deleted` | A note was deleted. |
| `conversation.assigned` | A member was assigned. `summary.member_id` is who. |
| `conversation.claimed` | A member assigned themselves. `summary.member_id` is who. |
| `conversation.unassigned` | A member was unassigned. `summary.member_id` is who. |
| `conversation.labelled` | A label was added. `summary.label_id` is which. |
| `conversation.unlabelled` | A label was removed, or deleted (`summary.label_deleted`). `summary.label_id` is which. |
| `conversation.archived` | The conversation was archived. |
| `conversation.unarchived` | The conversation came back to the inbox from the archive. |
| `conversation.snoozed` | The conversation was snoozed. `summary.until` is when it wakes. |
| `conversation.unsnoozed` | The conversation woke from a snooze, or was woken. |
| `conversation.spammed` | The conversation was marked as spam. |
| `conversation.unspammed` | The conversation was marked as not spam. |
| `conversation.trashed` | The conversation was moved to the trash. |
| `conversation.restored` | The conversation came back from the trash. |
| `conversation.vaulted` | The conversation was moved to the vault. A move of a whole date range from Settings has `summary.quiet` and sends no webhook. |
| `conversation.unvaulted` | The conversation came back to the inbox from the vault. |
| `conversation.ignored` | A member ignored the conversation. |
| `conversation.followed` | A member started following the conversation. |
| `conversation.unfollowed` | A member stopped following the conversation. |
| `conversation.mailboxes_changed` | The conversation's mailboxes changed. `summary.from_mailbox_ids` and `summary.mailbox_ids` are before and after. |
| `conversation.field_set` | A conversation field was set or cleared. `summary.key` is which. |
| `conversation.renamed` | The conversation's subject was changed. |
| `conversation.split` | This conversation was split from another. `summary.from_id` is which. |
| `conversation.split_off` | Messages were split off into a new conversation. `summary.to_id` is which. |
| `conversation.draft_taken_over` | A member took over editing a draft reply. |
| `contact.created` | A contact was created. |
| `contact.updated` | A contact's name, addresses, note or other details changed. |
| `contact.merged` | Another contact was merged into this one. `summary.merged_id` is the one that went, which no longer exists. |
| `contact.field_set` | A contact field was set or cleared. `summary.key` is which. |

Any kind can also have `summary.quiet` set to `true`. That means
nobody was told about the change: it came from an import or a blocked
sender. Webhooks are not sent quiet changes.

| Parameter | In | Type | |
|---|---|---|---|
| `since` | query | integer | The `next_cursor` or `latest_seq` of an earlier response. Without it, the response is the latest page. |
| `kinds` | query | string | Only these kinds, comma-separated, such as `conversation.archived,message.received`. |
| `mailbox_id` | query | integer | Only changes to conversations in this mailbox. |
| `wait` | query | integer | Seconds to wait for a change when there is none yet, 0 to 25. The default is 0. |
| `limit` | query | integer | Results per page, 1 to 100. The default is 50. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `410`: `since` is older than the oldest change kept, or is not from this feed.
- `422`: A `since` that is not a number, a kind that does not exist, a `mailbox_id` the caller cannot see, or a `wait` out of range.

Example `200`:

```json
{
  "changes": [
    {
      "seq": 4812,
      "at": "2026-03-04T09:20:00Z",
      "kind": "conversation.assigned",
      "conversation_id": "broken-widget-k3f9zq",
      "message_id": null,
      "note_id": null,
      "contact_id": null,
      "actor": {
        "type": "api_token",
        "id": 7,
        "name": "The night shift"
      },
      "summary": {
        "member_id": 3
      }
    }
  ],
  "next_cursor": 4812,
  "latest_seq": 4812,
  "has_more": false
}
```

Example `410`:

```json
{
  "error": "cursor_expired",
  "message": "That cursor is older than the oldest change kept, or not from this feed. Resync from the lists, then start again from latest_seq."
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Statistics

The team's figures, as shown on the Statistics page.

### `GET /api/statistics`

Get statistics

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Any token can call it, including a Member's personal token. The figures count only the conversations the caller can see.

Returns the same figures as
[Statistics](/manual/manage/statistics), which explains how
each one is counted.

The figures count only the conversations the caller can see. A team
token counts the mailboxes it was given. A personal token counts the
mailboxes its person is in, whatever their role. So the figures are the
whole team's only if the caller can see every mailbox, and two callers
can get different figures for the same period. The response does not
say whether any mailbox was left out.

- `needs_attention`: open conversations waiting for a reply, longest
  first. `waiting_over_24h` and `waiting_over_8h` mark the ones the
  Statistics page highlights.
- `members`: one entry per member, in id order. `away` is `null` if the
  member is available.
- `daily_volume`, `response_time_trend`, `awaiting_reply_trend`: one
  entry per day, oldest first.
- `response_time_trend`: each day's `median_sec` is the median first
  response time over the three days ending that day, or `null` if there
  were none.
- `conversation_depth`: how many messages the period's conversations
  have. The last bucket's `max_messages` is `null`.
- `first_responses_counted`: how many conversations the median is based
  on.

Conversations in spam or the trash are not counted.

| Parameter | In | Type | |
|---|---|---|---|
| `period` | query | integer | Number of days, 7 to 90. The default is 30. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `422`: A `period` outside 7 to 90.

Example `200`:

```json
{
  "statistics": {
    "period_days": 30,
    "from": "2026-02-05T00:00:00Z",
    "to": "2026-03-07T00:00:00Z",
    "summary": {
      "conversations_started": 214,
      "conversations_open": 18,
      "awaiting_reply": 4,
      "first_response_median_sec": 5400,
      "first_responses_counted": 198
    },
    "daily_volume": [
      {
        "date": "2026-03-06",
        "inbound": 12,
        "outbound": 9
      }
    ],
    "needs_attention": [
      {
        "id": "where-is-my-order-k3f9zq",
        "subject": "Where is my order?",
        "waiting_seconds": 93600,
        "waiting_over_24h": true,
        "waiting_over_8h": true,
        "url": "https://jelly.acme.test/conversations/where-is-my-order-k3f9zq"
      }
    ],
    "members": [
      {
        "member": {
          "type": "member",
          "id": 3,
          "name": "Sarah Chen"
        },
        "replies_sent": 41,
        "notes_added": 12,
        "conversations_archived": 30,
        "away": null
      }
    ],
    "response_time_trend": [
      {
        "date": "2026-03-06",
        "median_sec": 4800,
        "responses": 21
      }
    ],
    "awaiting_reply_trend": [
      {
        "date": "2026-03-06",
        "awaiting_reply": 4
      }
    ],
    "conversation_depth": {
      "conversations": 214,
      "average_messages": 2.9,
      "buckets": [
        {
          "label": "1 message",
          "min_messages": 1,
          "max_messages": 1,
          "conversations": 38
        },
        {
          "label": "2–3 messages",
          "min_messages": 2,
          "max_messages": 3,
          "conversations": 121
        },
        {
          "label": "4–6 messages",
          "min_messages": 4,
          "max_messages": 6,
          "conversations": 41
        },
        {
          "label": "7–10 messages",
          "min_messages": 7,
          "max_messages": 10,
          "conversations": 10
        },
        {
          "label": "More than 10",
          "min_messages": 11,
          "max_messages": null,
          "conversations": 4
        }
      ]
    }
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


## Inbound

How this Jelly receives email, for setting up mail delivery.

### `GET /api/inbound`

Get inbound email setup

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

With a personal token, the person must be an Admin or the Owner; anyone else gets `403 forbidden`. Any team token can call it.

Returns the domain this Jelly receives email at, whether the inbound
hook is set up, the sending domains, and every address with how it
receives email.

- `relay_address`: the address an SES receipt rule must name for this
  address. Empty for an address that does not receive through the relay.
- `default_inbound_address`: the address the team forwards email to, if
  one is set. A receipt rule must name it too.
- `inbound`: how the address receives email. `gmail` means through a
  Gmail connection, not the relay.
- `provider`: the service the address sends through: `postmark`, `gmail`
  or `smtp`.
- `secret_set`: whether the hook's secret is set. The secret itself is
  never returned.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `403`: A person who is not an Admin.

Example `200`:

```json
{
  "origin": "https://jelly.acme.test",
  "receiving_domain": "in.jelly.acme.test",
  "default_inbound_address": "jelly@in.jelly.acme.test",
  "hook": {
    "path": "/hooks/ses/inbound",
    "configured": true,
    "secret_set": true
  },
  "sending_domains": [
    "acme.test"
  ],
  "relay_addresses": 1,
  "addresses": [
    {
      "email": "support@acme.test",
      "name": "Support",
      "inbound": "relay",
      "inbound_local": "support",
      "relay_address": "support@in.jelly.acme.test",
      "verification": "domain",
      "provider": "postmark"
    },
    {
      "email": "someone@gmail.test",
      "name": "A connected mailbox",
      "inbound": "gmail",
      "inbound_local": "someone",
      "relay_address": "",
      "verification": "gmail",
      "provider": "gmail"
    }
  ]
}
```

Example `403`:

```json
{
  "error": "forbidden",
  "message": "Inbound routing is visible to Admins and the owner."
}
```


## Files

Files and pictures.

To attach a file to an inserted message, a note or a draft, upload it
first with `POST /files`, then give its `id` to the route that attaches
it.

Most of these are signed links. A signed link needs no token: its
`expires` and `signature` are the credential. Threads, drafts and saved
replies give you signed links already. A changed link returns `404`, and
an expired one returns `410`.

### `POST /api/files`

Upload a file

Accepts: **Team token** · **Personal token** · Scope: `write` · Tier: **Integration**

Uploads one file, sent as `multipart/form-data` in a field named
`file`. Any type is accepted, up to 10 MB. An empty file returns `422`.

The answer's `id` is what you attach: in `attachments` on
`POST /api/messages` and `POST /api/conversations/{id}/notes`, or as
`file_id` on `POST /api/drafts/{id}/attachments`.

- Only the caller who uploaded a file can attach it. Anyone else gets
  `404`.
- A file can be attached once. A second attempt returns `409`. To
  attach the same file twice, upload it twice.
- A file that is not attached by `expires_at`, 24 hours after the
  upload, is deleted. Attaching it after that returns `404`.
- A caller can have 100 files waiting to be attached. More returns
  `429`.

`content_type` is the type the upload declared, except for pictures,
whose type is read from the file itself. `filename` is the name the
upload gave, without any folder, control characters or text-direction
marks, and cut to 200 bytes. The same rules apply to a file sent
directly to a note or a draft.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (multipart/form-data):

| Field | Type | |
|---|---|---|
| `file` | binary, required | One file of any type, up to 10 MB. |

Answers:

- `201`: The file, waiting to be attached.
- `413`: Over 10 MB.
- `422`: No file, an empty one, or a body that is not multipart.
- `429`: This caller already has 100 files waiting to be attached.

Example `201`:

```json
{
  "file": {
    "id": "file_k3f9zq2m7xw4b8n5c6d2",
    "filename": "screenshot.png",
    "content_type": "image/png",
    "byte_size": 48213,
    "expires_at": "2026-03-05T09:20:00Z"
  }
}
```

Example `413`:

```json
{
  "error": "too_large",
  "message": "That file is over 10 MB"
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```

Example `429`:

```json
{
  "error": "too_many_files",
  "message": "100 uploaded files are waiting to be attached. Attach them, or wait for them to expire"
}
```


### `GET /api/attachments/{id}`

Get a file link

Accepts: **Team token** · **Personal token** · Scope: `read` · Tier: **Integration**

Redirects to a signed link for a file, valid for five minutes. Returns
`404` if the file is on a conversation the caller cannot see.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The attachment's id, a number. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `302`: Redirects to a signed download link, valid for five minutes.
- `404`: No such file, or not visible to this token.


### `GET /api/attachments/{id}/download`

Download a file

Accepts: **Signed link**

Downloads a message's or note's file. Valid for five minutes.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The attachment's id, a number. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: No such file, or the signature is wrong.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


### `GET /api/draft_attachments/{id}/download`

Download a draft's file

Accepts: **Signed link**

Downloads a draft's file. Valid for five minutes.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The draft attachment's id, a number. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: No such file, or the signature is wrong.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


### `GET /api/saved_reply_files/{id}/download`

Download a saved reply's file

Accepts: **Signed link**

Downloads a saved reply's file. Valid for five minutes.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | The saved reply file's id, a number. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: No such file, or the signature is wrong.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


### `GET /api/uploads/{hash}/download`

Download an uploaded picture

Accepts: **Signed link**

A picture from the text of a note, reply or draft. Valid for five
minutes.

| Parameter | In | Type | |
|---|---|---|---|
| `hash` | path | string, required | The picture's hash, the last segment of its `/uploads/{hash}` address. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: No such picture, or the signature is wrong.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


### `GET /api/avatars/{hash}/download`

Download an avatar

Accepts: **Signed link**

A member's or contact's picture. Valid for at least a day.

| Parameter | In | Type | |
|---|---|---|---|
| `hash` | path | string, required | The picture's hash. Key an image cache by it rather than by the whole link. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: No such picture, or the signature is wrong.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


### `GET /api/team/logo/{hash}/download`

Download the team's logo

Accepts: **Signed link**

| Parameter | In | Type | |
|---|---|---|---|
| `hash` | path | string, required | Identifies the logo. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: No such picture, or the signature is wrong.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


### `GET /api/remote`

Fetch a remote picture

Accepts: **Signed link**

A picture from another website in a message, fetched by Jelly so the
other site does not see the reader. Valid for an hour.

| Parameter | In | Type | |
|---|---|---|---|
| `u` | query | string, required | The picture's URL. |
| `expires` | query | integer, required | From the signed link. |
| `signature` | query | string, required | From the signed link. |

Answers:

- `200`: The file.
- `404`: The signature is wrong, or the picture could not be fetched.
- `410`: The link has expired. Fetch a new one.

Example `410`:

```json
{
  "error": "expired",
  "message": "This link has expired."
}
```


## Webhooks

A webhook sends a JSON request to your URL when something happens in
Jelly. Set webhooks up on **Settings → Webhooks**, or with the endpoints
below. See [Webhooks](/manual/advanced/webhooks) in the manual.

### Events

Every kind in the [change feed](#tag/changes) is an event, with the
same name: `conversation.archived`, `message.received`, `note.added`,
`message.delivery_updated`, `contact.updated` and the rest. What each
one means is listed under **Kinds** there. A webhook gets only the
events it subscribes to.

The five event names from before the change feed still work:
`new_message` (`message.received` and `message.sent`, not counting the
autoresponder's replies), `assigned` (`conversation.assigned` and
`conversation.claimed`), `note_added`, `conversation_archived` and
`conversation_unarchived`. Each keeps its old `data`. Only version
`2026-10-09` can subscribe a webhook to them; from `2026-10-22` they
are refused with `422`. A webhook already subscribed to one is sent it
until 22 October 2027, when they are removed. Use the new names.

`ping` is sent only by [Send a test](#tag/webhooks/POST/api/webhooks/{id}/test).

### Mailboxes

A webhook hears every mailbox (`all_mailboxes: true`), or only the
mailboxes in `mailbox_ids`. A webhook with `mailbox_ids` gets an event
about a conversation only when the conversation is in one of those
mailboxes. When a conversation moves out of them, the webhook gets
`conversation.mailboxes_changed`, and `data.conversation` has only its
`id`. Contact events go to every webhook that subscribes to them,
because contacts belong to the whole team.

A team token that can see only some mailboxes can only make webhooks
for those mailboxes. It cannot see or change a webhook that hears any
other mailbox.

Nothing is sent about a conversation in spam, except
`conversation.spammed` itself. Nothing is sent for mail brought in by an
import.

### The request

```json
{ "event": "conversation.archived", "seq": 4182, "created_at": "2026-03-01T10:30:00Z", "data": { … } }
```

`seq` is the change's place in the [change feed](#tag/changes).
`created_at` is when the change happened. `data` uses the same objects
as the REST API, without `avatar` or `stamp`, as they are when the
request is made. `data.actor` is who did it, and `data.summary` is the
change's summary from the feed.

A request from a [sandbox team](/manual/advanced/api#sandbox) also has
`"sandbox": true`. Other teams' requests do not have the field.

### Headers

| Header | |
| --- | --- |
| `X-Jelly-Event` | The event |
| `X-Jelly-Timestamp` | Unix seconds, when this try was sent |
| `X-Jelly-Signature` | `sha256=` and the hex HMAC-SHA256 of the request body, using the webhook's signing secret |
| `X-Jelly-Delivery` | The delivery's id, the same on every try of it |
| `Authorization` | Basic auth, if the webhook has a username or password |

Check the signature against the raw request body, before parsing it:

```ruby
"sha256=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, raw_body)
```

### Responding

Respond with a `2xx` status within ten seconds. Any other answer, or
none, is a failure, and the delivery is tried again: ten minutes after
the first try, then at 30 minutes, 1, 2, 4, 8 and 16 hours, and at 24,
36 and 48 hours, after which Jelly gives up and emails the team's
Admins. A retry carries the same body, byte for byte, re-signed, with
the same `X-Jelly-Delivery`. Ten deliveries given up on inside a day
switch the webhook off; see
[Delivery](/manual/advanced/webhooks#delivery) in the manual.

Jelly sends only to addresses on the public internet. It checks every
address the URL's host resolves to, as it connects. If any of them is
private, loopback, link-local or otherwise internal, nothing is sent,
and the try is a failure with the error `the address is private`. A
self-hosted instance can allow named networks; see [Receivers on your
own network](/manual/advanced/webhooks#receivers-on-your-own-network).
Jelly does not follow redirects: a `3xx` answer is a failure. Jelly
reads at most 64 KB of your answer.

### What is guaranteed

- A delivery is sent at least once. The same delivery can arrive more
  than once. Keep the `X-Jelly-Delivery` ids you have seen and ignore a
  repeat.
- Order is not guaranteed. Two changes made close together can arrive
  in either order, and a retry arrives after everything sent since. Use
  `seq` to put events in order: a larger `seq` happened later.
- A change can be missed: while the webhook is off, while the team's
  plan has no webhooks, after Jelly gives up on a delivery, or when
  Jelly restarts in the moment after a change. To catch up, keep the
  largest `seq` you have handled and call
  [GET /changes](#tag/changes/GET/api/changes) with `since` set to it. The
  feed keeps 30 days.
- Deliveries are kept for 30 days. A delivery can be sent again with
  [redeliver](#tag/webhooks/POST/api/webhooks/{id}/deliveries/{delivery}/redeliver)
  during that time.

### Webhook `conversation.created`

Conversation opened: see `conversation.created` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.created",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.assigned`

Assigned: see `conversation.assigned` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `member` | Member, or null | The member `summary.member_id` names. |

Example payload:

```json
{
  "event": "conversation.assigned",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "member": {
      "id": 3,
      "name": "Sarah Chen"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.claimed`

Claimed: see `conversation.claimed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `member` | Member, or null | The member `summary.member_id` names. |

Example payload:

```json
{
  "event": "conversation.claimed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "member": {
      "id": 3,
      "name": "Sarah Chen"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unassigned`

Unassigned: see `conversation.unassigned` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `member` | Member, or null | The member `summary.member_id` names. |

Example payload:

```json
{
  "event": "conversation.unassigned",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "member": {
      "id": 3,
      "name": "Sarah Chen"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.labelled`

Label added: see `conversation.labelled` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.labelled",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unlabelled`

Label removed: see `conversation.unlabelled` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.unlabelled",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.archived`

Archived: see `conversation.archived` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.archived",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unarchived`

Unarchived: see `conversation.unarchived` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.unarchived",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.snoozed`

Snoozed: see `conversation.snoozed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.snoozed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unsnoozed`

Woken from a snooze: see `conversation.unsnoozed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.unsnoozed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.spammed`

Marked as spam: see `conversation.spammed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.spammed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unspammed`

Marked as not spam: see `conversation.unspammed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.unspammed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.trashed`

Moved to the trash: see `conversation.trashed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.trashed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.restored`

Restored from the trash: see `conversation.restored` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.restored",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.vaulted`

Moved to the vault: see `conversation.vaulted` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.vaulted",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unvaulted`

Moved out of the vault: see `conversation.unvaulted` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.unvaulted",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.ignored`

Ignored: see `conversation.ignored` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.ignored",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.followed`

Followed: see `conversation.followed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.followed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.unfollowed`

Unfollowed: see `conversation.unfollowed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.unfollowed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.mailboxes_changed`

Mailboxes changed: see `conversation.mailboxes_changed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.mailboxes_changed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.field_set`

Field set: see `conversation.field_set` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.field_set",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.renamed`

Renamed: see `conversation.renamed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.renamed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.split`

Split from another: see `conversation.split` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.split",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.split_off`

Messages split off: see `conversation.split_off` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.split_off",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `conversation.draft_taken_over`

Draft taken over: see `conversation.draft_taken_over` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |

Example payload:

```json
{
  "event": "conversation.draft_taken_over",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.received`

Message received: see `message.received` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.received",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.sent`

Message sent: see `message.sent` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.sent",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.forwarded`

Message forwarded by a rule: see `message.forwarded` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.forwarded",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.unwrapped`

Forwarded message unwrapped: see `message.unwrapped` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.unwrapped",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.rewrapped`

Unwrapped message put back: see `message.rewrapped` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.rewrapped",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.reported`

Message reported: see `message.reported` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.reported",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.report_withdrawn`

Report withdrawn: see `message.report_withdrawn` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.report_withdrawn",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.report_answered`

Report answered: see `message.report_answered` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.report_answered",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.report_closed`

Report closed: see `message.report_closed` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.report_closed",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `note.added`

Note added: see `note.added` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `note` | object, or null | The note, as in `GET .../notes`, without `reactions` or `deletable_until`. `null` once it is deleted. |

Example payload:

```json
{
  "event": "note.added",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "note": {
      "id": "n8x2",
      "html_body": "<p>Sending a new one</p>"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `note.updated`

Note edited: see `note.updated` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `note` | object, or null | The note, as in `GET .../notes`, without `reactions` or `deletable_until`. `null` once it is deleted. |

Example payload:

```json
{
  "event": "note.updated",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "note": {
      "id": "n8x2",
      "html_body": "<p>Sending a new one</p>"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `note.deleted`

Note deleted: see `note.deleted` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `note` | object, or null | The note, as in `GET .../notes`, without `reactions` or `deletable_until`. `null` once it is deleted. |

Example payload:

```json
{
  "event": "note.deleted",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "note": {
      "id": "n8x2",
      "html_body": "<p>Sending a new one</p>"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `contact.created`

Contact created: see `contact.created` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `contact` | Contact, or null | The contact as it is now. |

Example payload:

```json
{
  "event": "contact.created",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "contact": {
      "id": 12,
      "name": "Ann Lee",
      "email": "ann@customer.test"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `contact.updated`

Contact updated: see `contact.updated` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `contact` | Contact, or null | The contact as it is now. |

Example payload:

```json
{
  "event": "contact.updated",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "contact": {
      "id": 12,
      "name": "Ann Lee",
      "email": "ann@customer.test"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `contact.merged`

Contacts merged: see `contact.merged` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `contact` | Contact, or null | The contact as it is now. |

Example payload:

```json
{
  "event": "contact.merged",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "contact": {
      "id": 12,
      "name": "Ann Lee",
      "email": "ann@customer.test"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `contact.field_set`

Contact field set: see `contact.field_set` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `contact` | Contact, or null | The contact as it is now. |

Example payload:

```json
{
  "event": "contact.field_set",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "contact": {
      "id": 12,
      "name": "Ann Lee",
      "email": "ann@customer.test"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `message.delivery_updated`

Delivery changed: see `message.delivery_updated` under Changes.

`data`:

| Field | Type | |
|---|---|---|
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |
| `summary` | object, required | The change's summary, as in `GET /api/changes`: ids and states. |
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, or null | The message, or `null` if it has gone. |

Example payload:

```json
{
  "event": "message.delivery_updated",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "actor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "summary": {}
  }
}
```


### Webhook `ping`

A test, sent when asked for. Not a change.

Sent by a test. `data.webhook` is the webhook's `id`, `url` and
`events`.

`data`:

| Field | Type | |
|---|---|---|
| `webhook` | object, required | The webhook's `id`, `url` and `events`. |

Example payload:

```json
{
  "event": "ping",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "webhook": {
      "id": 2,
      "url": "https://example.test/jelly",
      "events": [
        "conversation.archived"
      ]
    }
  }
}
```


### Webhook `new_message`

Deprecated: use `message.received` and `message.sent`.

Deprecated: subscribe to `message.received` and `message.sent`. A
message arrived, or someone on the team sent one. `new_conversation`
is `true` when the message started the conversation.

`data`:

| Field | Type | |
|---|---|---|
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `message` | Message, required |  |
| `new_conversation` | boolean, required | This message started the conversation. |

Example payload:

```json
{
  "event": "new_message",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "message": {
      "id": "k3f9zq",
      "direction": "inbound"
    },
    "new_conversation": true
  }
}
```


### Webhook `assigned`

Deprecated: use `conversation.assigned` and `conversation.claimed`.

Deprecated: subscribe to `conversation.assigned` and
`conversation.claimed`. `assigned_by` is a member, a token, or `null`
when a rule did it.

`data`:

| Field | Type | |
|---|---|---|
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `assignee` | Member, required |  |
| `assigned_by` | object, or null | A member, a token, or `null` for Jelly. |

Example payload:

```json
{
  "event": "assigned",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "assignee": {
      "id": 3,
      "name": "Sarah Chen"
    },
    "assigned_by": {
      "type": "api_token",
      "id": 7,
      "name": "The night shift"
    }
  }
}
```


### Webhook `note_added`

Deprecated: use `note.added`.

Deprecated: subscribe to `note.added`.

`data`:

| Field | Type | |
|---|---|---|
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `note` | object, required | The note, as in `GET .../notes`, without `reactions` or `deletable_until`. |

Example payload:

```json
{
  "event": "note_added",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "note": {
      "id": "n8x2",
      "html_body": "<p>Sending a new one</p>"
    }
  }
}
```


### Webhook `conversation_archived`

Deprecated: use `conversation.archived`.

Deprecated: subscribe to `conversation.archived`. `actor` is who did
it.

`data`:

| Field | Type | |
|---|---|---|
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |

Example payload:

```json
{
  "event": "conversation_archived",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": null
  }
}
```


### Webhook `conversation_unarchived`

Deprecated: use `conversation.unarchived`.

Deprecated: subscribe to `conversation.unarchived`. `actor` is who did
it, or `null` if Jelly did.

`data`:

| Field | Type | |
|---|---|---|
| `conversation` | Conversation, required | The conversation as it is now. Only `id`, for a webhook that does not hear the mailbox the conversation moved to. |
| `actor` | object, or null | Who did it: a member, a token, or `null` for Jelly. |

Example payload:

```json
{
  "event": "conversation_unarchived",
  "seq": 4182,
  "created_at": "2026-03-01T10:30:00Z",
  "data": {
    "conversation": {
      "id": "broken-widget-k3f9zq",
      "subject": "Broken widget"
    },
    "actor": null
  }
}
```


### `GET /api/webhooks`

List webhooks

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Returns every webhook the caller may manage, oldest first. The
`secret` is not included.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK

Example `200`:

```json
{
  "webhooks": [
    {
      "id": 2,
      "url": "https://ops.acme.test/jelly",
      "events": [
        "conversation.archived",
        "note.added"
      ],
      "all_mailboxes": false,
      "mailbox_ids": [
        4
      ],
      "active": true,
      "deactivated_at": null,
      "basic_auth_username": "",
      "has_basic_auth_password": false,
      "consecutive_failures": 0,
      "last_success_at": "2026-03-01T10:31:00Z",
      "last_error": null,
      "last_error_at": null,
      "created_at": "2026-03-01T09:00:00Z",
      "updated_at": "2026-03-01T09:00:00Z"
    }
  ],
  "next_cursor": null
}
```


### `POST /api/webhooks`

Add a webhook

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Adds a webhook. Give `url`, `events`, and either `all_mailboxes: true`
or `mailbox_ids`. The answer includes the signing `secret`. Keep it:
it is shown only here and when it is rotated.

| Parameter | In | Type | |
|---|---|---|---|
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `url` | string, required | An https URL. |
| `events` | array of string, required | The events to send: change kinds. Version `2026-10-09` also accepts the old names. |
| `all_mailboxes` | boolean | `true` to hear every mailbox, private ones and new ones included. |
| `mailbox_ids` | array of integer | The mailboxes to hear, instead of `all_mailboxes`. |
| `basic_auth_username` | string |  |
| `basic_auth_password` | string |  |
| `active` | boolean | `false` stops all sending. `true` turns it back on and clears the failure count. |

Example body:

```json
{
  "url": "https://ops.acme.test/jelly",
  "events": [
    "conversation.archived",
    "note.added"
  ],
  "mailbox_ids": [
    4
  ]
}
```

Answers:

- `201`: The webhook, with its `secret`. This is the only time it is shown, apart from a rotation.
- `403`: Webhooks are not on the team's plan.
- `422`: A `url` that is not https, no `events` or an unknown one, or neither `all_mailboxes: true` nor `mailbox_ids`. A token that cannot see every mailbox cannot use `all_mailboxes`.

Example `201`:

```json
{
  "webhook": {
    "id": 2,
    "secret": "9b1e…",
    "url": "https://ops.acme.test/jelly",
    "events": [
      "conversation.archived",
      "note.added"
    ],
    "all_mailboxes": false,
    "mailbox_ids": [
      4
    ],
    "active": true,
    "deactivated_at": null,
    "basic_auth_username": "",
    "has_basic_auth_password": false,
    "consecutive_failures": 0,
    "last_success_at": "2026-03-01T10:31:00Z",
    "last_error": null,
    "last_error_at": null,
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `GET /api/webhooks/{id}`

Get a webhook

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Returns one webhook, without its `secret`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such webhook, or one that hears mailboxes this token cannot see.

Example `200`:

```json
{
  "webhook": {
    "id": 2,
    "url": "https://ops.acme.test/jelly",
    "events": [
      "conversation.archived",
      "note.added"
    ],
    "all_mailboxes": false,
    "mailbox_ids": [
      4
    ],
    "active": true,
    "deactivated_at": null,
    "basic_auth_username": "",
    "has_basic_auth_password": false,
    "consecutive_failures": 0,
    "last_success_at": "2026-03-01T10:31:00Z",
    "last_error": null,
    "last_error_at": null,
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```


### `PATCH /api/webhooks/{id}`

Update a webhook

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Changes the fields you give and leaves the rest alone. Give
`all_mailboxes` or `mailbox_ids` to change what it hears. An empty
`basic_auth_password` removes the password. `active: true` turns a
webhook back on and resets its failure count.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Body (JSON):

| Field | Type | |
|---|---|---|
| `url` | string | An https URL. |
| `events` | array of string | The events to send: change kinds. Version `2026-10-09` also accepts the old names. |
| `all_mailboxes` | boolean | `true` to hear every mailbox, private ones and new ones included. |
| `mailbox_ids` | array of integer | The mailboxes to hear, instead of `all_mailboxes`. |
| `basic_auth_username` | string |  |
| `basic_auth_password` | string |  |
| `active` | boolean | `false` stops all sending. `true` turns it back on and clears the failure count. |

Example body:

```json
{
  "events": [
    "conversation.archived"
  ],
  "all_mailboxes": true
}
```

Answers:

- `200`: OK
- `403`: Webhooks are not on the team's plan. Turning one off is still allowed.
- `404`: No such webhook, or one that hears mailboxes this token cannot see.
- `422`: A field with the wrong value.

Example `200`:

```json
{
  "webhook": {
    "id": 2,
    "url": "https://ops.acme.test/jelly",
    "events": [
      "conversation.archived",
      "note.added"
    ],
    "all_mailboxes": false,
    "mailbox_ids": [
      4
    ],
    "active": true,
    "deactivated_at": null,
    "basic_auth_username": "",
    "has_basic_auth_password": false,
    "consecutive_failures": 0,
    "last_success_at": "2026-03-01T10:31:00Z",
    "last_error": null,
    "last_error_at": null,
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `DELETE /api/webhooks/{id}`

Remove a webhook

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Removes the webhook, its deliveries and its waiting retries.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `204`: Removed, with its deliveries.
- `404`: No such webhook, or one that hears mailboxes this token cannot see.


### `POST /api/webhooks/{id}/secret`

Rotate a webhook's secret

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Makes a new signing secret and returns it. Requests are signed with the
new secret at once, so update your receiver first.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The webhook, with its new `secret`.
- `404`: No such webhook, or one that hears mailboxes this token cannot see.

Example `200`:

```json
{
  "webhook": {
    "id": 2,
    "secret": "9b1e…",
    "url": "https://ops.acme.test/jelly",
    "events": [
      "conversation.archived",
      "note.added"
    ],
    "all_mailboxes": false,
    "mailbox_ids": [
      4
    ],
    "active": true,
    "deactivated_at": null,
    "basic_auth_username": "",
    "has_basic_auth_password": false,
    "consecutive_failures": 0,
    "last_success_at": "2026-03-01T10:31:00Z",
    "last_error": null,
    "last_error_at": null,
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-01T09:00:00Z"
  }
}
```


### `POST /api/webhooks/{id}/test`

Send a test

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Sends the `ping` event now and waits for the answer. The answer is the
try, with `ok` and `status` from your URL. A test is never tried again
and never counts towards switching the webhook off. It works on a
webhook that is off. Its `seq` is the newest change in the feed, a
place to start reading from.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The try. `ok` and `status` are what the URL answered.
- `403`: Webhooks are not on the team's plan.
- `404`: No such webhook, or one that hears mailboxes this token cannot see.

Example `200`:

```json
{
  "delivery": {
    "id": 811,
    "delivery_id": "5c2f0a9e1b7d3c4a6e8f0b12",
    "event": "ping",
    "seq": 4182,
    "ok": true,
    "status": 200,
    "error": null,
    "attempt": 1,
    "at": "2026-03-01T10:40:00Z",
    "retry_at": null,
    "gave_up": false,
    "manual": true
  }
}
```


### `GET /api/webhooks/{id}/deliveries`

List a webhook's deliveries

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Returns the webhook's tries from the last 30 days, newest first. Each
try of one delivery has the same `delivery_id`.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `limit` | query | integer | 1 to 100. The default is 50. |
| `cursor` | query | string | `next_cursor` from the page before. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |

Answers:

- `200`: OK
- `404`: No such webhook, or one that hears mailboxes this token cannot see.
- `422`: A bad `limit` or `cursor`.

Example `200`:

```json
{
  "deliveries": [
    {
      "id": 811,
      "delivery_id": "5c2f0a9e1b7d3c4a6e8f0b12",
      "event": "ping",
      "seq": 4182,
      "ok": true,
      "status": 200,
      "error": null,
      "attempt": 1,
      "at": "2026-03-01T10:40:00Z",
      "retry_at": null,
      "gave_up": false,
      "manual": true
    }
  ],
  "next_cursor": null
}
```

Example `422`:

```json
{
  "error": "unprocessable",
  "message": "The request could not be accepted.",
  "errors": [
    {
      "field": "until",
      "code": "invalid",
      "message": "until must be in the future"
    }
  ]
}
```


### `POST /api/webhooks/{id}/deliveries/{delivery}/redeliver`

Send a delivery again

Accepts: **Team token** · **Personal token** · Scope: `settings` · Tier: **Integration**

Needs **change team settings**: a team token with that permission, or a person who is an Admin or the Owner. Anyone else gets `403 forbidden`.

Sends a delivery again now: the same body, re-signed, with the same
`X-Jelly-Delivery`. The answer is the new try. If it succeeds, any
retry still waiting for that delivery is cancelled. A redelivery never
counts towards switching the webhook off.

| Parameter | In | Type | |
|---|---|---|---|
| `id` | path | string, required | Identifies the webhook. |
| `delivery` | path | string, required | The `delivery_id`, which is `X-Jelly-Delivery`. |
| `Jelly-Version` | header | string: `2026-10-22` \| `2026-10-09` | The dated version to answer as. Without it, the version the token or app is pinned to. See Versions. |
| `Idempotency-Key` | header | string | 1 to 255 characters. Send the same request again with the same key and Jelly does not repeat it: you get the first answer back. See Retrying safely. |

Answers:

- `200`: The new try.
- `403`: Webhooks are not on the team's plan.
- `404`: No such webhook, or no such delivery in the last 30 days.

Example `200`:

```json
{
  "delivery": {
    "id": 811,
    "delivery_id": "5c2f0a9e1b7d3c4a6e8f0b12",
    "event": "ping",
    "seq": 4182,
    "ok": true,
    "status": 200,
    "error": null,
    "attempt": 1,
    "at": "2026-03-01T10:40:00Z",
    "retry_at": null,
    "gave_up": false,
    "manual": true
  }
}
```


## Shared objects

### Conversation

A conversation. A personal token also gets `unread`, `read_at`, `bookmarked`, `following`, `ignored`, `mine` and `claimable`.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required | Its slug. |
| `external_id` | string, or null | Your own id for it, set by `PATCH` or when a message is inserted. Always present beside `fields`; on other rows, only when set. |
| `fields` | object | Its field values, by key. A field with no value is left out. On a single conversation, and on rows with `include=fields`. |
| `subject` | string, required |  |
| `thread_subject` | string, required | The subject of the messages, if the team renamed the conversation. |
| `status` | string: `open` \| `archived` \| `snoozed` \| `spam` \| `trash` \| `vaulted`, required |  |
| `snoozed_until` | date-time, required, or null |  |
| `message_count` | integer, required |  |
| `created_at` | date-time, required |  |
| `last_activity_at` | date-time, required |  |
| `last_message_at` | date-time, required |  |
| `has_draft` | boolean, required |  |
| `has_attachments` | boolean, required | Whether its messages have files. |
| `url` | string, required | Its page in the web app. |
| `messages_url` | string, required | `GET /api/conversations/{id}/messages`. |
| `notes_url` | string, required | `GET /api/conversations/{id}/notes`. |
| `markdown_url` | string, required | `GET /api/conversations/{id}/markdown`. |
| `thread_url` | string | `GET /api/conversations/{id}/thread`. Only for a personal token, on a row of a list. |
| `labels` | array of Label, required |  |
| `assignees` | array of Member, required |  |
| `mailboxes` | array of Mailbox, required |  |
| `contact` | Contact, required, or null | Who the conversation is with. In lists, only `id`, `name`, `email` and `avatar`. |
| `snippet` | string |  |
| `latest_note` | object, required, or null | The newest note, if nothing newer has happened. |
| `last_from` | Address | Who sent the last message. |
| `last_from_me` | boolean | Whether the team sent the last message. |
| `stamp` | string | Changes when the conversation changes. |
| `match` | string | From a search with `include_matches=true`: the text that matched, as HTML with `<mark>` around the match. |
| `messages` | array of Message | Its messages, oldest first. Only on a single conversation. |
| `reply_from` | SendingAddress | The address a reply is sent from. Only on a single conversation. |
| `reply` | object | Who a reply goes to, and which message it answers. Only on a single conversation. |
| `unread` | boolean | Personal tokens only. |
| `read_at` | date-time, or null | When this person last read it. Personal tokens only. |
| `bookmarked` | boolean | Personal tokens only. |
| `following` | boolean | Personal tokens only. |
| `ignored` | boolean | Personal tokens only. |
| `mine` | boolean | Assigned to this person. Personal tokens only. |
| `claimable` | boolean | This person can take it with **I got this**. Personal tokens only. |


### ListRow

A row of a list: a conversation, or a draft that has no conversation yet.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required, or null | Its slug, or `null` for a draft that does not belong to a conversation yet. |
| `external_id` | string, or null | Your own id for it, set by `PATCH` or when a message is inserted. Always present beside `fields`; on other rows, only when set. |
| `fields` | object | Its field values, by key. A field with no value is left out. On a single conversation, and on rows with `include=fields`. |
| `subject` | string, required |  |
| `thread_subject` | string | The subject of the messages, if the team renamed the conversation. |
| `status` | string: `open` \| `archived` \| `snoozed` \| `spam` \| `trash` \| `vaulted` |  |
| `snoozed_until` | date-time, or null |  |
| `message_count` | integer |  |
| `created_at` | date-time |  |
| `last_activity_at` | date-time, required |  |
| `last_message_at` | date-time |  |
| `has_draft` | boolean, required |  |
| `has_attachments` | boolean, required | Whether its messages have files. |
| `url` | string | Its page in the web app. |
| `messages_url` | string | `GET /api/conversations/{id}/messages`. |
| `notes_url` | string | `GET /api/conversations/{id}/notes`. |
| `markdown_url` | string | `GET /api/conversations/{id}/markdown`. |
| `thread_url` | string | `GET /api/conversations/{id}/thread`. Only for a personal token, on a row of a list. |
| `labels` | array of Label |  |
| `assignees` | array of Member |  |
| `mailboxes` | array of Mailbox |  |
| `contact` | Contact, or null | Who the conversation is with. In lists, only `id`, `name`, `email` and `avatar`. |
| `snippet` | string |  |
| `latest_note` | object, or null | The newest note, if nothing newer has happened. |
| `last_from` | Address | Who sent the last message. |
| `last_from_me` | boolean | Whether the team sent the last message. |
| `stamp` | string | Changes when the conversation changes. |
| `match` | string | From a search with `include_matches=true`: the text that matched, as HTML with `<mark>` around the match. |
| `messages` | array of Message | Its messages, oldest first. Only on a single conversation. |
| `reply_from` | SendingAddress | The address a reply is sent from. Only on a single conversation. |
| `reply` | object | Who a reply goes to, and which message it answers. Only on a single conversation. |
| `unread` | boolean | Personal tokens only. |
| `read_at` | date-time, or null | When this person last read it. Personal tokens only. |
| `bookmarked` | boolean | Personal tokens only. |
| `following` | boolean | Personal tokens only. |
| `ignored` | boolean | Personal tokens only. |
| `mine` | boolean | Assigned to this person. Personal tokens only. |
| `claimable` | boolean | This person can take it with **I got this**. Personal tokens only. |
| `draft` | DraftSummary | In the drafts and scheduled lists, the draft. |


### InboxSection

A section of the inbox.

Fields:

| Field | Type | |
|---|---|---|
| `key` | string, required | `new`, `mine`, `unassigned` or `others`. More may be added. |
| `title` | string, required |  |
| `total` | integer, required |  |
| `unread` | integer, required |  |
| `colour` | object | The section's colours, for light and dark. |
| `conversations` | array of Conversation, required |  |
| `next` | string, required, or null | The list with the rest of the section, when there is more. |


### Message

One message.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required | Its key. |
| `conversation_id` | string, required |  |
| `direction` | string: `inbound` \| `outbound`, required |  |
| `sender_type` | string: `contact` \| `member` \| `system`, required | Who sent it: someone outside the team, a member, or Jelly. |
| `from` | Address, required |  |
| `to` | array of Address, required |  |
| `cc` | array of Address, required |  |
| `bcc` | array of Address, required |  |
| `subject` | string, required |  |
| `snippet` | string, required |  |
| `text_body` | string, required |  |
| `html_body` | string, required | Cleaned HTML, as shown in the web app. |
| `automatic` | boolean, required | Sent automatically, such as an autoresponse or a bounce. |
| `message_id` | string, required | Its `Message-ID` header. |
| `in_reply_to` | string, required | Its `In-Reply-To` header. |
| `sent_at` | date-time, required |  |
| `attachments` | array of Attachment, required |  |
| `url` | string, required |  |
| `relay_email` | string | The relay address it arrived through, when it came by the relay. |
| `headers` | object | The message's headers. Only from `GET /api/messages/{key}`. |
| `authentication` | object | `spf`, `dkim` and `dmarc` results, each `null` when there is none. Only from `GET /api/messages/{key}`. |
| `delivery` | object | For a sent message, whether it was delivered. |
| `source` | string: `email` \| `api`, required | `api` if it was put in with `POST /api/messages`; `email` otherwise. |
| `inserted_by` | Actor, or null | Who put it in: a member or a token. Only when `source` is `api`. |


### Address

An email address and the name with it.

Fields:

| Field | Type | |
|---|---|---|
| `name` | string, required | Empty when the address came without one. |
| `email` | string, required |  |
| `avatar` | Avatar | Not in a webhook's payload. |


### SendingAddress

One of the team's addresses that mail can be sent from.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `address` | string, required |  |
| `name` | string, required |  |
| `from_name` | string | The name mail sent from it by this person carries. |
| `default` | boolean | On a draft that names no address: this is the one it would go from. |


### Attachment

A file on a message, a note, a draft or a saved reply.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `filename` | string, required |  |
| `content_type` | string, required |  |
| `byte_size` | integer, required |  |
| `url` | string, required | Where to get it: an API route that redirects to a signed link, or a signed link itself. |
| `inline` | boolean | A picture in the message's own layout, rather than a file. On a message's and a note's files. |


### File

A file uploaded with `POST /api/files`, waiting to be attached.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required | Name it in `attachments` to attach it. |
| `filename` | string, required |  |
| `content_type` | string, required |  |
| `byte_size` | integer, required |  |
| `expires_at` | date-time, required | When it is thrown away if nothing attaches it. |


### Actor

Who did something: a member, or a team token. Where it can be `null`, `null` means Jelly did it.

Fields:

| Field | Type | |
|---|---|---|
| `type` | string: `member` \| `api_token`, required |  |
| `id` | integer, required |  |
| `name` | string, required |  |
| `email` | string | A member's. |
| `role` | string: `owner` \| `admin` \| `member` | A member's. |
| `active` | boolean | A member's. |
| `avatar` | Avatar | A member's. Not in a webhook's payload. |
| `via` | string | The assistant or registered app a member acted through, such as `Claude`, when there was one. |


### Note

A note: the team's own words on a conversation, never sent.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required | Its key. |
| `conversation_id` | string, required |  |
| `html_body` | string, required |  |
| `text_body` | string, required |  |
| `created_at` | date-time, required |  |
| `edited_at` | date-time, required, or null |  |
| `url` | string, required |  |
| `author` | Actor, required, or null |  |
| `attachments` | array of Attachment, required |  |
| `reactions` | array of Reaction | Not in a webhook's payload. |
| `deletable_until` | date-time, or null | Until when its author may delete it. Not in a webhook's payload. |


### Reaction

An emoji a member put on a note.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `emoji` | string, required |  |
| `member` | Actor, required |  |
| `mine` | boolean, required | Whether the person asking put it there. |


### Draft

A reply or a new message being written.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `conversation_id` | string, required, or null | `null` for a new message or a forward, until it is sent. |
| `subject` | string, required |  |
| `html_body` | string, required |  |
| `inline_images` | object, required | Signed links to draw the body's pictures by, keyed by the `/uploads/{hash}` the body names them by. |
| `to` | array of string, required |  |
| `cc` | array of string, required |  |
| `bcc` | array of string, required |  |
| `from` | SendingAddress, required, or null | The address it goes from. `null` when nothing can send from the address it names. |
| `mailbox_id` | integer, required, or null | The mailbox a new message's conversation starts in. `null` for a reply. |
| `locked` | boolean, required | Somebody is editing it now. |
| `editor` | Actor, required, or null | Who is editing it, while `locked`. |
| `started_by` | Actor, required, or null |  |
| `last_edited_by` | Actor, required, or null |  |
| `attachments` | array of Attachment, required |  |
| `scheduled_for` | date-time, required, or null | When it will be sent, once it is on its way. |
| `scheduled_by` | Actor, required, or null | Whose send it is, once it is on its way. |
| `created_at` | date-time, required |  |
| `updated_at` | date-time, required |  |
| `url` | string | Its conversation's page. Only on a reply. |
| `lock_token` | string | Personal tokens only: proves you hold the draft. Send it with `send`, `lock` and `DELETE`. |
| `client_key` | string | The `client_key` it was made with, for the person who made it. |


### DraftSummary

A draft, as a row of the drafts or scheduled list shows it.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `conversation_id` | string, required, or null |  |
| `subject` | string, required |  |
| `to` | array of string, required |  |
| `cc` | array of string, required |  |
| `updated_at` | date-time, required |  |
| `locked` | boolean, required |  |
| `url` | string, required | Where to open it in the web app. |
| `scheduled_for` | date-time, required, or null |  |
| `scheduled_by` | Actor, required, or null |  |


### ThreadConversation

The conversation at the head of a thread.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required |  |
| `subject` | string, required |  |
| `status` | string: `open` \| `archived` \| `snoozed` \| `spam` \| `trash` \| `vaulted`, required |  |
| `labels` | array of Label, required |  |
| `assignees` | array of Member, required |  |
| `followers` | array of Member, required |  |
| `mailboxes` | array of Mailbox, required |  |
| `contact` | Contact, required, or null |  |
| `following` | boolean, required |  |
| `url` | string, required |  |
| `reply_from` | SendingAddress, required |  |
| `reply` | object, required |  |


### ThreadItem

One item of a thread, as the web app draws it. `kind` says which fields it has: a `message`, a `note`, an `activity` line, the `draft` being written, or a `scheduled` send. More kinds may be added: skip one you do not know.

Fields:

| Field | Type | |
|---|---|---|
| `kind` | string: `message` \| `note` \| `activity` \| `draft` \| `scheduled`, required |  |
| `id` | string | A message's or a note's key. |
| `draft_id` | integer | A draft's or a scheduled send's id. |
| `from` | object, or null | Who a message is from, or the address a draft goes from. |
| `to` | array of Address |  |
| `cc` | array of Address |  |
| `direction` | string: `inbound` \| `outbound` |  |
| `subject` | string |  |
| `sent_at` | date-time |  |
| `at` | date-time | When a note was written, or an activity happened. |
| `updated_at` | date-time |  |
| `html` | string |  |
| `text` | string |  |
| `inline_images` | object |  |
| `remote_images_blocked` | integer | How many pictures from other websites were held back. |
| `attachments` | array of Attachment |  |
| `url` | string |  |
| `author` | Actor, or null |  |
| `reactions` | array of Reaction |  |
| `deletable_until` | date-time, or null |  |
| `verb` | string | What an activity was, such as `assigned`. |
| `line` | string | An activity, as a sentence of HTML. |
| `actor` | Actor, or null |  |
| `target` | Member, or null |  |
| `locked` | boolean |  |
| `editor` | Actor, or null |  |
| `scheduled_for` | date-time, or null |  |
| `undo_until` | date-time, or null |  |
| `scheduled_by` | Actor, or null |  |


### Change

One change to the team's mail, from `GET /api/changes`.

Fields:

| Field | Type | |
|---|---|---|
| `seq` | integer, required | Its place in the feed. A larger `seq` happened later. |
| `at` | date-time, required |  |
| `kind` | string, required | What happened, such as `conversation.archived`. More kinds may be added. |
| `conversation_id` | string, required, or null |  |
| `message_id` | string, required, or null |  |
| `note_id` | string, required, or null |  |
| `contact_id` | string, required, or null |  |
| `actor` | Actor, required, or null |  |
| `summary` | object, required | Ids and states that say what changed, by kind, such as `member_id` or `label_id`. |


### Label

A label.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required | Its slug. It stays the same when the label is renamed. |
| `name` | string, required |  |
| `colour` | string, required |  |
| `created_at` | date-time, required |  |


### Mailbox

A mailbox. Mailboxes decide which conversations members and tokens can see.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `name` | string, required |  |
| `default` | boolean, required | Every member is in the default mailbox. |


### Member

A member of the team.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `name` | string, required | Their email address, if they have not set a name. |
| `email` | string, required |  |
| `role` | string: `owner` \| `admin` \| `member`, required |  |
| `active` | boolean | Not on `GET /api/me`, whose member is always active. |
| `avatar` | Avatar | Not in a webhook's payload. |
| `type` | string: `member` | Where a member stands for who did something. |


### Avatar

How to show a person's picture.

Fields:

| Field | Type | |
|---|---|---|
| `url` | string, required, or null | A signed link to their picture, or `null` to show initials. |
| `initials` | string, required |  |
| `tint` | object, required | Colours for initials, for light and dark. |


### Contact

A person the team writes to.

Fields:

| Field | Type | |
|---|---|---|
| `id` | string, required | Its slug. |
| `external_id` | string, or null | Your own id for them, set by `POST /api/contacts`. Always present on a contact asked for on its own; on a conversation's contact, only when set. |
| `email` | string, required |  |
| `name` | string, required |  |
| `note` | string |  |
| `links` | object | Links, as name and URL pairs. |
| `fields` | object | The contact's field values, by key: a string, a number, a boolean, or a date as `YYYY-MM-DD`. A field with no value is left out. Not on a conversation's contact. |
| `blocked` | boolean |  |
| `never_spam` | boolean |  |
| `avatar` | Avatar | Not in a webhook's payload. |
| `created_at` | date-time |  |
| `updated_at` | date-time |  |


### AddressSuggestion

An address to offer while someone types a recipient.

Fields:

| Field | Type | |
|---|---|---|
| `contact_id` | string, required | The contact's id. |
| `name` | string, required | The contact's name. Empty when they have none. |
| `email` | string, required |  |
| `value` | string, required | The recipient to write in `to`, `cc` or `bcc`: the name and the address, with the name quoted when it needs to be. |


### ContactField

A field the team keeps on every contact.

Fields:

| Field | Type | |
|---|---|---|
| `key` | string, required | Its id, in `fields` and in search. It never changes. |
| `label` | string, required | Its name, as people read it. |
| `type` | string: `text` \| `number` \| `date` \| `boolean` \| `url` \| `choice`, required |  |
| `options` | array of string | Only for a `choice`. |
| `position` | integer, required | Where it is shown, from 1. |
| `created_at` | date-time, required |  |
| `updated_at` | date-time, required |  |


### CardSource

One of the team's systems that Jelly asks for contact cards.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `name` | string, required |  |
| `url` | string, required |  |
| `secret` | string, required | Signs each request in `X-Jelly-Signature`. |
| `timeout_seconds` | integer, required |  |
| `active` | boolean, required |  |
| `created_by` | Member, required, or null | Who added it, or `null` when a token did. |
| `created_at` | date-time, required |  |
| `updated_at` | date-time, required |  |


### ContactCard

One card source's answer about a conversation's contact.

Fields:

| Field | Type | |
|---|---|---|
| `source` | object, required |  |
| `card` | object, required, or null | The card, or `null` when the source did not give one. |
| `error` | string, required, or null | Why there is no card, in words to show. |
| `fetched_at` | date-time, required, or null |  |


### Webhook

A URL Jelly sends events to.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `url` | string, required |  |
| `events` | array of string, required |  |
| `all_mailboxes` | boolean, required | Whether it hears every mailbox. |
| `mailbox_ids` | array of integer, required | The mailboxes it hears, when not all of them. |
| `active` | boolean, required |  |
| `deactivated_at` | date-time, required, or null | When Jelly turned it off after repeated failures. |
| `basic_auth_username` | string, required |  |
| `has_basic_auth_password` | boolean, required |  |
| `consecutive_failures` | integer, required |  |
| `last_success_at` | date-time, required, or null |  |
| `last_error` | string, required, or null |  |
| `last_error_at` | date-time, required, or null |  |
| `secret` | string | Only when it is made or rotated. |
| `created_at` | date-time, required |  |
| `updated_at` | date-time, required |  |


### WebhookDelivery

One try at sending an event to a webhook.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `delivery_id` | string, required, or null | `X-Jelly-Delivery`, the same on every try of one delivery. |
| `event` | string, required |  |
| `seq` | integer, required, or null | The change it is about, as in `GET /api/changes`. |
| `ok` | boolean, required |  |
| `status` | integer, required, or null | The HTTP status the URL answered, or `null` for no answer. |
| `error` | string, required, or null |  |
| `attempt` | integer, required | Which try of the delivery this was, from 1. |
| `at` | date-time, required |  |
| `retry_at` | date-time, required, or null | When the next try is due. |
| `gave_up` | boolean, required | Whether this was the last try. |
| `manual` | boolean, required | Whether somebody asked for this try: a test or a redelivery. |


### ConversationField

A field the team keeps on every conversation.

Fields:

| Field | Type | |
|---|---|---|
| `key` | string, required | Its id, in `fields`, in `field.<key>=` and in search. It never changes. |
| `label` | string, required | Its name, as people read it. |
| `type` | string: `text` \| `number` \| `date` \| `boolean` \| `url` \| `choice`, required |  |
| `options` | array of string | Only for a `choice`. |
| `position` | integer, required | Where it is shown, from 1. |
| `created_at` | date-time, required |  |
| `updated_at` | date-time, required |  |


### SavedReply

A saved reply: words to start a reply with.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `name` | string, required |  |
| `subject` | string, required |  |
| `html_body` | string, required |  |
| `text_body` | string, required |  |
| `inline_images` | object, required | Signed links to draw the body's pictures by, keyed by the `/uploads/{hash}` the body names them by. |
| `attachments` | array of Attachment, required | Its files. `POST /api/drafts/{id}/saved_reply_files` copies them onto a draft. |
| `created_at` | date-time, required |  |
| `updated_at` | date-time, required |  |


### Statistics

The team's figures over a period.

Fields:

| Field | Type | |
|---|---|---|
| `period_days` | integer, required |  |
| `from` | date-time, required |  |
| `to` | date-time, required |  |
| `summary` | object, required |  |
| `daily_volume` | array of object, required |  |
| `needs_attention` | array of object, required | Conversations waiting longest for a reply. |
| `members` | array of object, required |  |
| `response_time_trend` | array of object, required |  |
| `awaiting_reply_trend` | array of object, required |  |
| `conversation_depth` | object, required |  |


### Installation

An app the team installed from a manifest.

Fields:

| Field | Type | |
|---|---|---|
| `id` | integer, required |  |
| `name` | string, required |  |
| `client_id` | string, required |  |
| `scopes` | array of string, required | What the app may ask for. |
| `granted_scopes` | array of string, required | What this token's grant holds. |
| `manifest_url` | string, required, or null |  |
| `manifest_hash` | string, required |  |
| `webhook` | Webhook, required, or null |  |
| `card_source` | object, required, or null |  |
| `mailboxes` | array of Mailbox, required | The mailboxes the app can see. |
| `installed_at` | date-time, required |  |
| `updated_at` | date-time, required |  |


### Autoresponder

The team's default autoresponder.

Fields:

| Field | Type | |
|---|---|---|
| `enabled` | boolean, required |  |
| `message` | string, required | HTML. |
| `first_message_only` | boolean, required |  |
| `mark_conversation_as_read` | boolean, required |  |


## Shared examples

### `conversation`

A conversation.

Example:

```json
{
  "conversation": {
    "id": "broken-widget-k3f9zq",
    "subject": "Broken widget",
    "thread_subject": "Broken widget",
    "status": "open",
    "snoozed_until": null,
    "message_count": 3,
    "created_at": "2026-03-04T08:00:00Z",
    "last_activity_at": "2026-03-04T09:20:00Z",
    "last_message_at": "2026-03-04T09:12:00Z",
    "has_draft": false,
    "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq",
    "messages_url": "https://jelly.acme.test/api/conversations/broken-widget-k3f9zq/messages",
    "notes_url": "https://jelly.acme.test/api/conversations/broken-widget-k3f9zq/notes",
    "markdown_url": "https://jelly.acme.test/api/conversations/broken-widget-k3f9zq/markdown",
    "labels": [
      {
        "id": "billing",
        "name": "Billing",
        "colour": "lemon",
        "created_at": "2026-01-02T10:00:00Z"
      }
    ],
    "assignees": [
      {
        "id": 3,
        "name": "Sarah Chen",
        "email": "sarah@acme.test",
        "role": "admin",
        "active": true
      }
    ],
    "mailboxes": [
      {
        "id": 1,
        "name": "Support",
        "default": true
      }
    ],
    "contact": {
      "id": "jane-customer",
      "email": "jane@customer.test",
      "name": "Jane Customer"
    },
    "snippet": "Thanks for getting back to us so quickly…",
    "latest_note": null,
    "last_from": {
      "name": "Jane Customer",
      "email": "jane@customer.test"
    },
    "last_from_me": false,
    "has_attachments": true,
    "stamp": "lz3k9f:0:-"
  }
}
```


### `draft`

A draft, as returned to a personal token.

Example:

```json
{
  "draft": {
    "id": 41,
    "conversation_id": "broken-widget-k3f9zq",
    "subject": "Re: Broken widget",
    "html_body": "<p>Thanks Jane, a new one is on its way.</p>",
    "inline_images": {},
    "to": [
      "Jane Customer <jane@customer.test>"
    ],
    "cc": [],
    "bcc": [],
    "from": {
      "id": 1,
      "address": "support@acme.test",
      "name": "Support",
      "from_name": "Sarah Chen (Support)"
    },
    "mailbox_id": null,
    "locked": true,
    "editor": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "started_by": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "last_edited_by": {
      "type": "member",
      "id": 3,
      "name": "Sarah Chen"
    },
    "attachments": [],
    "scheduled_for": null,
    "scheduled_by": null,
    "created_at": "2026-03-04T09:10:00Z",
    "updated_at": "2026-03-04T09:12:00Z",
    "url": "https://jelly.acme.test/conversations/broken-widget-k3f9zq",
    "lock_token": "2BxI…"
  },
  "quoted": ""
}
```

