๐Ÿ‘‘ API

Jelly provides a simple REST API that lets you manage labels, contacts, and conversations programmatically. You can use the API to integrate Jelly with your CRM, build automations, or sync contact data from other tools.

This feature is only available on the Royal Jelly plan

Already have an integration? See the Changelog for what’s new and the few response changes to check when you upgrade.

Authentication

All API requests require an API token. You can create one from Settings โ†’ API Tokens in your team settings.

Include your token in the Authorization header of every request:

Authorization: Bearer YOUR_API_TOKEN

API tokens have full access to your team’s data โ€” treat them like passwords. Never share tokens in public repositories or client-side code. Jelly automatically tracks when each token was last used.

Base URL

https://app.letsjelly.com/api

Pagination

List endpoints (GET /api/conversations, GET /api/conversations/:id/messages, and GET /api/conversations/:id/comments) are cursor-paginated. Each returns an envelope:

{
  "conversations": [ โ€ฆ ],
  "next_cursor": "eyJ0cyI6..."
}

(The array key is named after the resource โ€” conversations or messages.)

Parameter Description
limit Page size, from 1 to 100. Defaults to 50.
cursor An opaque token from a previous response’s next_cursor. Omit it for the first page.

To walk a list, keep requesting with the next_cursor from the previous response. When next_cursor is null, you’ve reached the last page. A malformed cursor returns 422.

# first page
curl "https://app.letsjelly.com/api/conversations?limit=50" -H "Authorization: Bearer YOUR_API_TOKEN"
# next page
curl "https://app.letsjelly.com/api/conversations?limit=50&cursor=eyJ0cyI6..." -H "Authorization: Bearer YOUR_API_TOKEN"

The conversation feed is ordered by most recent activity, which changes as new mail arrives. Paging is designed for syncing, not for a frozen snapshot: a conversation that receives a new message while you’re paging can move position, so treat the cursor as “continue from here,” not a guarantee that the underlying list held still. Message lists within a conversation are stable, since a sent message’s position doesn’t change.

Conversations

List Conversations

Return conversations for your team, newest activity first. By default this excludes spam, trash, and draft-only conversations (use status=spam or status=trash to see those).

GET /api/conversations
Parameter Required Description
status No One of open, archived, snoozed, spam, trash
label_id No Only return conversations carrying this label
mailbox_id No Only return conversations in this mailbox
limit No Page size, from 1 to 100. Defaults to 50
cursor No Pagination cursor from a previous next_cursor (see Pagination)

The status value matches the status field returned for each conversation. Omitting it returns every active conversation โ€” everything except spam, trash, and draft-only.

Example request:

curl "https://app.letsjelly.com/api/conversations?status=open&label_id=urgent&limit=20" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

A paginated envelope: the conversations array plus a next_cursor.

{
  "conversations": [
    {
      "id": "stair-car",
      "subject": "Stair car incident",
      "status": "open",
      "messages_count": 3,
      "comments_count": 1,
      "attachments_count": 0,
      "mailboxes": [
        {
          "id": "general",
          "name": "General",
          "default": true,
          "members_count": 4,
          "created_at": "2026-05-27T09:00:00Z",
          "updated_at": "2026-05-27T09:00:00Z"
        }
      ],
      "snoozed_until": null,
      "url": "https://app.letsjelly.com/teams/your-team/conversations/stair-car",
      "markdown_url": "https://app.letsjelly.com/api/conversations/stair-car.markdown",
      "messages_url": "https://app.letsjelly.com/api/conversations/stair-car/messages",
      "comments_url": "https://app.letsjelly.com/api/conversations/stair-car/comments",
      "draft_reply_url": "https://app.letsjelly.com/api/conversations/stair-car/draft_reply",
      "created_at": "2026-05-27T09:00:00Z",
      "updated_at": "2026-05-27T09:00:00Z",
      "last_message_at": "2026-05-27T09:00:00Z",
      "labels": [
        { "id": "urgent", "name": "Urgent", "color": "cherry" }
      ],
      "assignees": []
    }
  ],
  "next_cursor": "eyJ0cyI6..."
}

The messages array is omitted in list results for brevity. To read a thread, either fetch the conversation individually with GET /api/conversations/:id (which embeds the most recent messages) or page the full thread via messages_url โ€” see List Conversation Messages.

Load a Conversation

Return one conversation, including the most recent messages in the thread (draft messages are not included).

GET /api/conversations/:id

Example request:

curl "https://app.letsjelly.com/api/conversations/stair-car" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

The shape matches an item from GET /api/conversations (above), with the addition of a messages array. Each message uses the message shape.

The embedded messages array is capped at the 20 most recent messages (oldest-to-newest within that window). For a longer thread, page the full history โ€” oldest first โ€” through the conversation’s messages_url; see List Conversation Messages. messages_count always reflects the true total, and comments_count likewise reports the number of internal comments on the conversation (present on both the list and show responses).

Embedding comments and a merged timeline

By default the response carries messages only. Two opt-in query parameters add the team’s internal comments:

Parameter Adds Description
include_comments comments The 20 most recent comments, oldest-first โ€” the comment counterpart to the messages embed. Page the rest through comments_url.
timeline timeline Messages and comments merged into one oldest-first feed of the 40 most recent thread elements. Each entry is a tagged wrapper: { "type": "message", "message": { โ€ฆ } } or { "type": "comment", "comment": { โ€ฆ } }.

Both windows are previews; reconstruct the complete merged history by paging messages_url and comments_url and interleaving them by timestamp. The two parameters are independent and can be combined.

curl "https://app.letsjelly.com/api/conversations/stair-car?timeline=true" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
{
  "id": "stair-car",
  "subject": "Stair car incident",
  "status": "open",
  "messages_count": 3,
  "comments_count": 1,
  "attachments_count": 0,
  "mailboxes": [
    { "id": "general", "name": "General", "default": true, "members_count": 4 }
  ],
  "labels": [],
  "assignees": [],
  "messages": [
    {
      "id": "msg-3e4f8a9b-1234-โ€ฆ",
      "conversation_id": "stair-car",
      "subject": "Stair car",
      "inbound": true,
      "from": ["Sally Sitwell <sally@sitwell.test>"],
      "html_body": "<p>I've been meaning to return the stair car</p>",
      "โ€ฆ": "โ€ฆ"
    }
  ],
  "timeline": [
    { "type": "message", "message": { "id": "msg-3e4f8a9b-1234-โ€ฆ", "โ€ฆ": "โ€ฆ" } },
    { "type": "comment", "comment": { "id": "123", "body": "Heads up: Enterprise plan.", "author": { "type": "api_token", "โ€ฆ": "โ€ฆ" } } }
  ]
}

Markdown view (for AI tooling)

GET /api/conversations/:id.markdown

Returns a text/markdown document with the conversation formatted for LLM consumption (subject, participants, and the message bodies in plaintext). Useful when piping a conversation into a model for summarisation or classification.

Messages

Load a Message

Return a single message. The message must belong to a conversation in your team.

GET /api/messages/:id

Example request:

curl "https://app.letsjelly.com/api/messages/msg-3e4f8a9b-1234" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

{
  "id": "msg-3e4f8a9b-1234-โ€ฆ",
  "conversation_id": "stair-car",
  "subject": "Re: Stair car",
  "inbound": false,
  "sent_at": "2026-05-27T09:00:00Z",
  "created_at": "2026-05-27T09:00:00Z",
  "url": "https://app.letsjelly.com/teams/your-team/conversations/stair-car#message_123",
  "from": ["support@bluth.company"],
  "to": ["sally@sitwell.test"],
  "cc": [],
  "html_body": "<p>Thanks Sally โ€” we'll get it back to you next week.</p>",
  "text_body": "Thanks Sally โ€” we'll get it back to you next week.",
  "attachments_count": 0,
  "attachments": [],
  "sender": {
    "type": "member",
    "id": "42",
    "name": "Michael Bluth",
    "email": "michael@bluth.company"
  }
}

The sender is either a member (a team member) or a contact (an outside party), with the corresponding fields for each. For inbound messages from outside parties, sender.type is "contact". For drafts and replies sent by team members, it’s "member". sender is null for unowned shared drafts created through the API. See Attachments for the attachment object shape.

List Conversation Messages

Page through every sent message in a conversation, oldest first โ€” the natural order for reconstructing a thread. Draft messages are excluded. This is the paginated counterpart to the capped messages array embedded in GET /api/conversations/:id.

GET /api/conversations/:conversation_id/messages
Parameter Description
limit Page size, from 1 to 100. Defaults to 50.
cursor Pagination cursor from a previous next_cursor (see Pagination)

Example request:

curl "https://app.letsjelly.com/api/conversations/stair-car/messages?limit=50" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

A paginated envelope: the messages array (each using the message shape) plus a next_cursor.

{
  "messages": [
    { "id": "msg-3e4f8a9b-1234-โ€ฆ", "conversation_id": "stair-car", "subject": "Stair car", "โ€ฆ": "โ€ฆ" }
  ],
  "next_cursor": null
}

Because a sent message’s position never changes, paging this list is stable โ€” unlike the conversation feed, you won’t see rows shift between pages.

Labels

List Labels

Get all labels for your team, sorted alphabetically by name. Deleted labels are not included.

GET /api/labels

Example request:

curl "https://app.letsjelly.com/api/labels" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

[
  { "id": "bug", "name": "Bug", "color": "cherry" },
  { "id": "feature", "name": "Feature", "color": "mint" }
]

Create Label

Create a new label for your team.

POST /api/labels
Content-Type: application/json
Parameter Required Description
name Yes The label name (must be unique within your team)
color No One of the available colors listed below

Available colors:

gray gray-dark cherry cherry-dark apricot apricot-dark pineapple pineapple-dark gooseberry gooseberry-dark mint mint-dark juniper juniper-dark blueberry blueberry-dark huckleberry huckleberry-dark grape grape-dark plum plum-dark rhubarb rhubarb-dark strawberry strawberry-dark

Example request:

curl -X POST "https://app.letsjelly.com/api/labels" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Important", "color": "cherry"}'

Success response (201 Created):

{ "id": "important", "name": "Important", "color": "cherry" }

The id is generated from the name (URL-safe, lowercase). If two labels would generate the same id, the second one is suffixed with a number ("important-2").

Update Label

Rename or recolor a label. Renaming changes the label’s id; any cached ids in your integration will need to be refreshed.

PATCH /api/labels/:id
Content-Type: application/json
Parameter Required Description
name No The new label name
color No One of the available colors

Example request:

curl -X PATCH "https://app.letsjelly.com/api/labels/important" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Urgent", "color": "cherry-dark"}'

Success response (200 OK):

{ "id": "urgent", "name": "Urgent", "color": "cherry-dark" }

Delete Label

Soft-delete a label. The label is removed from every conversation it was applied to and no longer returned by the API. Existing labels with the same name can be re-created afterwards.

DELETE /api/labels/:id

Example request:

curl -X DELETE "https://app.letsjelly.com/api/labels/urgent" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response: 204 No Content (empty body)

Members

List Team Members

Return the active members of your team. Use this to discover member IDs and emails for assignment and draft-reply endpoints.

GET /api/members

Success response (200 OK):

[
  {
    "id": "42",
    "name": "Michael Bluth",
    "email": "michael@bluth.company",
    "role": "owner",
    "active": true
  },
  {
    "id": "43",
    "name": "Lindsay Bluth",
    "email": "lindsay@bluth.company",
    "role": "member",
    "active": true
  }
]

The id is the team membership identifier โ€” use it wherever member_id is accepted by the API. The email is the member’s account email address. Deactivated members are not included in the response.

Mailboxes

Mailboxes group conversations and control who in your team can see them. A conversation belongs to one or more mailboxes โ€” its mailboxes array lists them all. Every team has one default mailbox plus any extras they create, and a conversation is always in at least one.

List Mailboxes

Returns the default mailbox first, then other mailboxes alphabetically by name.

GET /api/mailboxes

Example request:

curl "https://app.letsjelly.com/api/mailboxes" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

[
  {
    "id": "general",
    "name": "General",
    "default": true,
    "members_count": 4,
    "created_at": "2026-05-27T09:00:00Z",
    "updated_at": "2026-05-27T09:00:00Z"
  },
  {
    "id": "billing",
    "name": "Billing",
    "default": false,
    "members_count": 2,
    "created_at": "2026-05-27T09:00:00Z",
    "updated_at": "2026-05-27T09:00:00Z"
  }
]

List Mailbox Members

Return the team members who can see conversations in this mailbox.

GET /api/mailboxes/:id/members

Example request:

curl "https://app.letsjelly.com/api/mailboxes/billing/members" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

Uses the same shape as GET /api/members.

[
  { "id": "42", "name": "Michael Bluth", "email": "michael@bluth.company", "role": "owner", "active": true },
  { "id": "43", "name": "Lindsay Bluth", "email": "lindsay@bluth.company", "role": "member", "active": true }
]

Conversation Labels

Add a Label to a Conversation

Apply an existing label to a conversation. The label must already exist on the team (use POST /api/labels first if you need to create it).

POST /api/conversations/:conversation_id/labels
Content-Type: application/json
Parameter Required Description
label_id Yes The id of the label to apply

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/labels" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label_id": "important"}'

Success response (201 Created):

Returns all labels currently on the conversation.

[
  { "id": "important", "name": "Important", "color": "cherry" }
]

If the label was already on the conversation, the endpoint returns 200 OK (still safe to call). The activity is attributed to the API token, so the conversation history shows the integration that applied the label.

Remove a Label from a Conversation

DELETE /api/conversations/:conversation_id/labels/:id

Example request:

curl -X DELETE "https://app.letsjelly.com/api/conversations/stair-car/labels/important" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

Returns the remaining labels on the conversation. Removing a label that wasn’t there is a no-op and still returns 200 OK.

[]

Conversation Actions

These endpoints mirror what team members do from the conversation toolbar in the Jelly UI. Each one returns the updated conversation summary (same shape as GET /api/conversations/:id without messages), so a single call leaves you with the fresh state. Repeating an action that has already been applied is safe and returns 200 OK with the current state.

The activity for each action is attributed to the API token (so it appears in the conversation history as e.g. “Stripe Integration archived the conversation”), not to a team member.

Archive and Unarchive

Archiving moves a conversation out of the inbox view. POST archives, DELETE un-archives.

POST   /api/conversations/:conversation_id/archive
DELETE /api/conversations/:conversation_id/archive

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/archive" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Move to Trash and Restore

POST   /api/conversations/:conversation_id/trash
DELETE /api/conversations/:conversation_id/trash

Conversations in the trash are still searchable but hidden from the inbox. They are not automatically deleted.

Mark Spam and Unspam

POST   /api/conversations/:conversation_id/spam
DELETE /api/conversations/:conversation_id/spam

Marking a conversation as spam helps Jelly learn what your team considers junk; subsequent messages from the same sender may be auto-routed.

Snooze and Unsnooze

Snoozing hides the conversation until snooze_until, when it returns to the snoozer’s inbox. The snooze belongs to a specific team member (the one who will see the wake-up); the activity, however, is attributed to the API token.

POST /api/conversations/:conversation_id/snooze
Content-Type: application/json
Parameter Required Description
snooze_until Yes ISO 8601 datetime in the future (e.g. "2026-05-28T09:00:00Z")
member_id Either one The id of the team member the snooze belongs to
email Either one The team member’s email โ€” looked up within your team only

Provide exactly one of member_id or email.

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/snooze" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"snooze_until": "2026-05-28T09:00:00Z", "email": "michael@bluth.company"}'

To wake a snoozed conversation immediately:

DELETE /api/conversations/:conversation_id/snooze

Ignore a Conversation

Ignoring is personal: the named team member opts out of seeing this conversation in their “Brand new” view. Other members are unaffected.

POST /api/conversations/:conversation_id/ignore
Content-Type: application/json
Parameter Required Description
member_id Either one The id of the team member to mark this ignored for
email Either one The team member’s email

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/ignore" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "michael@bluth.company"}'

Set Conversation Mailboxes

A conversation can belong to several mailboxes at once. This endpoint replaces the conversation’s entire mailbox set with the ids you provide: any mailbox in the list that the conversation isn’t already in is added, and any it’s currently in but you omit is removed. Send a single id to “move” the conversation so it lives in just that mailbox; send several to fan it out.

PATCH /api/conversations/:conversation_id/mailboxes
Content-Type: application/json
Parameter Required Description
mailbox_ids Yes Array of mailbox ids the conversation should belong to. Must contain at least one.

Each change is logged against the API token (added/removed activities appear in the conversation history). When a mailbox is removed, assignees who could only see the conversation through that mailbox are unassigned โ€” exactly as in the web app. The response is the updated conversation, whose mailboxes array reflects the new set.

Example request โ€” move to a single mailbox:

curl -X PATCH "https://app.letsjelly.com/api/conversations/stair-car/mailboxes" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mailbox_ids": ["billing"]}'

Example request โ€” place the conversation in two mailboxes:

curl -X PATCH "https://app.letsjelly.com/api/conversations/stair-car/mailboxes" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mailbox_ids": ["general", "billing"]}'

To return a conversation to your team’s default mailbox only, send the default mailbox’s id as the sole entry. An empty array is rejected with 422, since a conversation must always be in at least one mailbox.

Conversation Assignments

Assign a Conversation

Assign a conversation to a team member. You can identify the member by their team membership member_id (from GET /api/members) or by their email address. If the conversation is already assigned to that member, the endpoint is a no-op and returns 200 OK.

POST /api/conversations/:conversation_id/assignments
Content-Type: application/json
Parameter Required Description
member_id Either one The team membership ID to assign the conversation to
email Either one The member’s email address; resolved against members of your team only

Provide exactly one of member_id or email. Both identify the member within the current team only โ€” a member of another team cannot be assigned, even if the email or ID matches.

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/assignments" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "michael@bluth.company"}'

Success response (201 Created):

Returns the updated conversation, including the full assignees list.

{
  "id": "stair-car",
  "subject": "Stair car incident",
  "status": "open",
  "assignees": [
    {
      "id": "42",
      "name": "Michael Bluth",
      "email": "michael@bluth.company",
      "role": "owner",
      "active": true
    }
  ],
  "labels": []
}

If the member is already assigned, the response is 200 OK with the current state.

Assigning via the API notifies the assignee the same way a rule-driven assignment does (email + web-push, subject to the assignee’s notification settings). The activity appears in the conversation attributed to the API token, not to a team member.

Unassign a Conversation

Remove a team member’s assignment from a conversation. If the member is not currently assigned, the endpoint is a no-op and returns 200 OK.

DELETE /api/conversations/:conversation_id/assignments/:member_id

The member_id path segment is the team membership ID.

Success response (200 OK):

Returns the updated conversation with the member removed from assignees.

Autoresponder

Teams have at most one autoresponder, which optionally replies to brand-new inbound conversations. The endpoints below let you toggle it and update the message body without going through the UI โ€” useful for scheduled out-of-office switches.

Show Autoresponder

GET /api/autoresponder

Example request:

curl "https://app.letsjelly.com/api/autoresponder" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

{
  "enabled": false,
  "message": "",
  "updated_at": null
}

updated_at is null until the autoresponder has been edited at least once.

Update Autoresponder

Enable or disable the autoresponder and/or change its HTML message. Fields you don’t include are left untouched, so toggling on/off without re-sending the body is safe.

PATCH /api/autoresponder
Content-Type: application/json
Parameter Required Description
enabled No true or false
message No HTML body of the autoresponder reply

Example request:

curl -X PATCH "https://app.letsjelly.com/api/autoresponder" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "message": "<p>Thanks for your message. We'll reply soon.</p>"}'

Success response (200 OK):

Returns the updated autoresponder using the same shape as GET /api/autoresponder.

Draft Replies

Create a Draft Reply

Create a draft reply to the most recent message in a conversation. The draft will appear in the conversation in the Jelly UI, ready for a team member to review and send.

POST /api/conversations/:conversation_id/draft_reply
Content-Type: application/json
Parameter Required Description
body Yes The HTML body of the draft reply
member_id No The id of the team member to attribute the draft to. When provided, the draft appears as theirs and they can edit it immediately. When omitted, the draft is unowned and any team member can claim it.
message_id No The id of the specific message you want to reply to. Defaults to the most recent sent message in the conversation.
to No Override the To address (auto-populated from the original message if omitted)
cc No Override the CC address
bcc No Override the BCC address. If your team has an auto-BCC configured, it will be applied unless you provide this field.

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/draft_reply" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "<p>Thanks for reaching out! We'll look into this right away.</p>",
    "member_id": 42
  }'

Success response (201 Created):

Returns the draft message in the same shape as GET /api/messages/:id.

{
  "id": "msg-3e4f8a9b-1234-โ€ฆ",
  "conversation_id": "stair-car",
  "subject": "Re: Stair car",
  "inbound": false,
  "sent_at": null,
  "url": "https://app.letsjelly.com/teams/your-team/conversations/stair-car#message_123",
  "from": ["contact@bluth.company"],
  "to": ["sally@sitwell.test"],
  "cc": [],
  "html_body": "<p>Thanks for reaching out! We'll look into this right away.</p>",
  "text_body": "",
  "attachments_count": 0,
  "attachments": [],
  "sender": {
    "type": "member",
    "id": "42",
    "name": "Michael Bluth",
    "email": "michael@bluth.company"
  }
}

Conflict response (409 Conflict):

If a draft already exists for that message (or that message + member, in personal-drafts mode), the endpoint returns the existing draft in the body with status 409. This prevents you from accidentally overwriting a draft that a team member may have already edited. Inspect the response to decide whether to retry, append, or surface a message to the user.

Messages include an attachments array alongside attachments_count. Each attachment is { id, filename, content_type, byte_size, inline, url }. See Attachments below for how to fetch the files.

Update a Draft

Revise an existing draft message โ€” for example to rewrite the body after an automation has gathered more context. Only draft messages can be updated; calling this on a sent message returns 422.

PATCH /api/messages/:id
Content-Type: application/json
Parameter Required Description
body No New HTML body for the draft
subject No New subject
to No Override the To address
cc No Override the CC address
bcc No Override the BCC address

Only the fields you send are changed; omit a field to leave it as-is.

This mirrors the editing rules in the web app: if a team member is actively editing the draft (their editing lock is live), the update is rejected with 423 Locked and the editor’s name, so you don’t clobber work in progress. If no one is currently editing โ€” including the case where an editor opened the draft but has since gone idle past the lock timeout โ€” the update succeeds.

Example request:

curl -X PATCH "https://app.letsjelly.com/api/messages/msg-3e4f8a9b-1234" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "<p>Updated reply with the latest order details.</p>"}'

Success response (200 OK):

Returns the updated draft in the same shape as GET /api/messages/:id.

Locked response (423 Locked):

{ "error": "Draft is currently being edited by Michael Bluth" }

Retry once the editor stops editing (the lock clears on release, or automatically after a short idle timeout).

Comments

Comments are internal notes on a conversation โ€” visible to your team, never sent to the customer. Posting one through the API is how an automation surfaces context (an order status, a fraud signal, a CRM note) to the people working the conversation; reading them back lets an integration pick up context a teammate has already added.

List Comments

Page through a conversation’s internal comments, oldest first โ€” the same order as the message thread, so the two can be merged into one timeline. Deleted comments are kept as tombstones (deleted: true, with the body replaced by a placeholder), matching how they remain visible in the web UI. A conversation’s comments_url points here.

GET /api/conversations/:conversation_id/comments
Parameter Description
limit Page size, from 1 to 100. Defaults to 50.
cursor Pagination cursor from a previous next_cursor (see Pagination)

Example request:

curl "https://app.letsjelly.com/api/conversations/stair-car/comments?limit=50" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

Success response (200 OK):

A paginated envelope: the comments array (each using the same shape as Add a Comment) plus a next_cursor.

{
  "comments": [
    {
      "id": "123",
      "body": "Heads up: this customer is on the Enterprise plan.",
      "created_at": "2026-06-05T12:00:00Z",
      "author": {
        "id": "7",
        "name": "Billing sync",
        "type": "api_token"
      }
    }
  ],
  "next_cursor": null
}

As with the message thread, a comment’s position never changes, so paging this list is stable.

Add a Comment

Add an internal comment to a conversation, authored by the API token. The comment appears in the conversation timeline attributed to the token โ€” as โ€œAPI () commentedโ€ โ€” and notifies the team members subscribed to the conversation, exactly as a teammate’s comment would.

POST /api/conversations/:conversation_id/comments
Content-Type: application/json
Parameter Required Description
body Yes The comment body. Accepts HTML; the response returns it as plain text.

Example request:

curl -X POST "https://app.letsjelly.com/api/conversations/stair-car/comments" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "<p>Heads up: this customer is on the Enterprise plan.</p>"}'

Success response (201 Created):

{
  "id": "123",
  "deleted": false,
  "body": "Heads up: this customer is on the Enterprise plan.",
  "created_at": "2026-06-05T12:00:00Z",
  "deleted_at": null,
  "author": {
    "id": "7",
    "name": "Billing sync",
    "type": "api_token"
  }
}

The author block identifies who wrote the comment. A token-authored comment has type "api_token" with the token’s id and name. Comments written by a person in the web app instead carry type "team_membership" with that member’s name, email, and role.

A deleted comment is not removed from these responses โ€” it is returned as a tombstone with deleted set to true, a deleted_at timestamp, and its body replaced by a "This comment was deleted" placeholder, mirroring the web UI. comments_count counts tombstones too.

If body is empty or missing, the endpoint returns 422 Unprocessable Entity with { "error": "Body is required" }.

Only people can send customer-facing replies. API tokens can add internal comments, but they cannot send a message to the customer.

Contacts

Find Contact by Email

Look up an existing contact by their email address.

GET /api/contacts/for_email?email=alice@example.com
Parameter Required Description
email Yes The email address to look up

Success response (200 OK):

{
  "id": "abc123",
  "name": "Alice Smith",
  "email": "alice@example.com",
  "url": "https://app.letsjelly.com/teams/your-team/contacts/abc123"
}

Returns a 404 if no contact is found with that email address.

Create or Update Contact

Create a new contact or update an existing one. If a contact with the given email already exists, it will be updated instead of creating a duplicate.

POST /api/contacts
Content-Type: application/json
Parameter Required Description
email Yes The contact’s email address
name No The contact’s display name
note No A private note about the contact (supports HTML). When provided, replaces the existing note. Omit to leave unchanged.
links No An object mapping link titles to URLs. Replaces all existing links when provided.
labels No An array of label names. Replaces all existing contact labels when provided. Labels must already exist on the team.

Example request:

{
  "email": "alice@example.com",
  "name": "Alice Smith",
  "note": "VIP customer, handle with care",
  "links": {
    "LinkedIn": "https://linkedin.com/in/alice",
    "Website": "https://alice.example.com"
  },
  "labels": ["VIP", "Partner"]
}

Success response (201 Created or 200 OK):

Returns 201 when creating a new contact, or 200 when updating an existing one.

{
  "id": 42,
  "email": "alice@example.com",
  "name": "Alice Smith",
  "note": "VIP customer, handle with care",
  "links": [
    { "title": "LinkedIn", "url": "https://linkedin.com/in/alice" },
    { "title": "Website", "url": "https://alice.example.com" }
  ],
  "labels": [
    { "name": "VIP", "color": "cherry" },
    { "name": "Partner", "color": "mint" }
  ]
}

When links or labels are included in a request, they replace all existing values for that contact. The note field also replaces the existing note when provided. Omit these fields entirely if you only want to update the name.

Attachments

Messages in API and webhook payloads include an attachments array. Each entry carries metadata about the file plus a url for downloading it:

{
  "id": "eyJfcmFpbHMiOnsiZGF0YSI6MTIz...",
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "byte_size": 48291,
  "inline": false,
  "url": "https://app.letsjelly.com/api/attachments/eyJfcmFpbHMiOnsiZGF0YSI6MTIz..."
}

inline is true for images and other files that are referenced inline from the message’s HTML body (such as a signature image). Most integrations can filter these out and only process inline: false entries.

Download an Attachment

GET /api/attachments/:id

The :id is the attachment identifier from the attachments array. The endpoint verifies that the file belongs to a message in your team, then responds with a short-lived redirect (302) to the actual file in storage. Follow the redirect to download the file.

Most HTTP clients follow redirects automatically. If yours does not, call the Location header promptly โ€” the redirect target is only signed for a few minutes.

Why aren’t direct download URLs included inline?

Every attachment entry points at our API rather than directly at the file in storage. That’s deliberate:

  • Storage URLs expire quickly. Direct signed URLs from our object store are only valid for a few minutes. A webhook that sits in a queue or gets retried after a brief outage would receive a 403 by the time it follows the link.
  • Delivery is not synchronous. Payloads may be logged, persisted, or forwarded through intermediate systems. A long-lived direct URL floating through those systems is a bigger leak than an ID that still requires an API token to exchange for a download.
  • Authorization stays in your hands. Calling /api/attachments/:id requires your bearer token, so the download is tied to your integration rather than whoever captured the payload.

The id in the payload is stable โ€” you can refetch an attachment at any later time by calling the URL with your API token. Jelly mints a fresh short-lived download link on each request.

Error Handling

All API errors are returned as JSON, never HTML.

Status Meaning Example
401 Missing or invalid API token {"error": "Invalid API token"}
404 Resource or endpoint not found {"error": "Resource not found"}
409 Conflict (e.g. draft already exists) Returns the existing resource
422 Validation error {"errors": ["Name can't be blank"]}
423 Draft locked โ€” a team member is actively editing it {"error": "Draft is currently being edited by Michael Bluth"}
500 Server error {"error": "Internal server error"}

Examples

curl

# List labels
curl "https://app.letsjelly.com/api/labels" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# List open conversations
curl "https://app.letsjelly.com/api/conversations?status=open" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Load a conversation (embeds the most recent messages)
curl "https://app.letsjelly.com/api/conversations/my-conversation" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Page through a conversation's full message history (oldest first)
curl "https://app.letsjelly.com/api/conversations/my-conversation/messages?limit=50" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Load a single message
curl "https://app.letsjelly.com/api/messages/msg-123" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Create a label
curl -X POST "https://app.letsjelly.com/api/labels" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Bug", "color": "cherry"}'

# Apply label to a conversation
curl -X POST "https://app.letsjelly.com/api/conversations/my-conversation/labels" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label_id": "bug"}'

# Archive a conversation
curl -X POST "https://app.letsjelly.com/api/conversations/my-conversation/archive" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Snooze a conversation
curl -X POST "https://app.letsjelly.com/api/conversations/my-conversation/snooze" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"snooze_until": "2026-05-28T09:00:00Z", "member_id": 42}'

# Set the mailboxes a conversation belongs to
curl -X PATCH "https://app.letsjelly.com/api/conversations/my-conversation/mailboxes" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mailbox_ids": ["support"]}'

# Find a contact by email
curl "https://app.letsjelly.com/api/contacts/for_email?email=alice@example.com" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Create or update a contact
curl -X POST "https://app.letsjelly.com/api/contacts" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "name": "Alice Smith", "note": "VIP customer", "labels": ["VIP"]}'

# Create a draft reply in a conversation
curl -X POST "https://app.letsjelly.com/api/conversations/my-conversation/draft_reply" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "<p>Thanks for reaching out!</p>", "member_id": 42}'

# Update an existing draft (fails with 423 if a member is editing it)
curl -X PATCH "https://app.letsjelly.com/api/messages/msg-123" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "<p>Updated reply.</p>"}'

# List team members
curl "https://app.letsjelly.com/api/members" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# List mailboxes
curl "https://app.letsjelly.com/api/mailboxes" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Enable autoresponder
curl -X PATCH "https://app.letsjelly.com/api/autoresponder" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "message": "<p>Thanks for your message.</p>"}'

# Assign a conversation by email
curl -X POST "https://app.letsjelly.com/api/conversations/my-conversation/assignments" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "michael@bluth.company"}'

# Unassign a member from a conversation
curl -X DELETE "https://app.letsjelly.com/api/conversations/my-conversation/assignments/42" \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Download an attachment (follow redirects)
curl -L "https://app.letsjelly.com/api/attachments/ATTACHMENT_ID" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -o invoice.pdf

JavaScript

const API_TOKEN = 'YOUR_API_TOKEN';
const BASE_URL = 'https://app.letsjelly.com/api';

const headers = {
  'Authorization': `Bearer ${API_TOKEN}`,
  'Content-Type': 'application/json'
};

// List labels
async function listLabels() {
  const response = await fetch(`${BASE_URL}/labels`, { headers });
  return response.json();
}

// List one page of conversations. Returns { conversations, next_cursor }.
async function listConversations({ status, labelId, mailboxId, limit, cursor } = {}) {
  const params = new URLSearchParams();
  if (status) params.set('status', status);
  if (labelId) params.set('label_id', labelId);
  if (mailboxId) params.set('mailbox_id', mailboxId);
  if (limit) params.set('limit', limit);
  if (cursor) params.set('cursor', cursor);

  const query = params.toString();
  const response = await fetch(`${BASE_URL}/conversations${query ? `?${query}` : ''}`, { headers });
  return response.json();
}

// Walk every page, following next_cursor until it's null.
async function allConversations(filters = {}) {
  const all = [];
  let cursor = null;
  do {
    const { conversations, next_cursor } = await listConversations({ ...filters, cursor });
    all.push(...conversations);
    cursor = next_cursor;
  } while (cursor);
  return all;
}

// Load a conversation (embeds the most recent messages)
async function loadConversation(conversationId) {
  const response = await fetch(`${BASE_URL}/conversations/${conversationId}`, { headers });
  return response.json();
}

// Page a conversation's full message history (oldest first).
async function allMessages(conversationId) {
  const all = [];
  let cursor = null;
  do {
    const params = new URLSearchParams();
    if (cursor) params.set('cursor', cursor);
    const query = params.toString();
    const response = await fetch(
      `${BASE_URL}/conversations/${conversationId}/messages${query ? `?${query}` : ''}`,
      { headers }
    );
    const { messages, next_cursor } = await response.json();
    all.push(...messages);
    cursor = next_cursor;
  } while (cursor);
  return all;
}

// Create a label
async function createLabel(name, color) {
  const response = await fetch(`${BASE_URL}/labels`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ name, color })
  });
  return response.json();
}

// Snooze a conversation
async function snoozeConversation(conversationId, snoozeUntil, memberId) {
  const response = await fetch(`${BASE_URL}/conversations/${conversationId}/snooze`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ snooze_until: snoozeUntil, member_id: memberId })
  });
  return response.json();
}

// Find a contact by email
async function findContact(email) {
  const response = await fetch(
    `${BASE_URL}/contacts/for_email?email=${encodeURIComponent(email)}`,
    { headers }
  );
  return response.json();
}

// Create or update a contact
async function upsertContact(email, { name, note, links, labels } = {}) {
  const response = await fetch(`${BASE_URL}/contacts`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ email, name, note, links, labels })
  });
  return response.json();
}

// Create a draft reply in a conversation
async function createDraftReply(conversationId, body, { memberId, messageId } = {}) {
  const response = await fetch(`${BASE_URL}/conversations/${conversationId}/draft_reply`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ body, member_id: memberId, message_id: messageId })
  });
  return response.json();
}

// Update an existing draft (resolves to null if a member is editing it)
async function updateDraft(messageId, body) {
  const response = await fetch(`${BASE_URL}/messages/${messageId}`, {
    method: 'PATCH',
    headers,
    body: JSON.stringify({ body })
  });
  if (response.status === 423) return null; // someone is editing it right now
  return response.json();
}

Rate Limits

The API currently has no rate limits, but please be respectful and avoid making excessive requests.

Changelog

2026-05 โ€” Stable string ids, pagination, and new endpoints

This update standardises identifiers and adds a batch of endpoints. The good news for existing integrations: your requests keep working โ€” every lookup still accepts the numeric ids you may have stored. A few response shapes changed, though, so check the list below.

Action required

  1. Label and message ids in responses are now slugs, not numbers. GET /api/labels, POST /api/labels, the labels array inside a conversation, and the draft-reply response previously returned a numeric id (e.g. 42 or "123"). They now return a stable, URL-friendly string id (e.g. "important", "msg-3e4fโ€ฆ").

    • If you send a stored numeric label or message id, nothing breaks โ€” lookups accept both forms.
    • If you store or compare the id you get back, update it to the new string id (or re-sync). Conversation and contact ids are unchanged.
    • Note the type change too: label id went from a JSON number to a string.
  2. POST /api/labels now responds 201 Created instead of 200 OK. Relax any exact status-code check to “2xx”.

  3. POST /api/conversations/:id/labels changed in two ways.

    • On failure the body is now { "error": "โ€ฆ" } (a string) rather than { "errors": [ โ€ฆ ] } (an array).
    • Applying a label that’s already on the conversation is now idempotent โ€” it returns 200 OK with the current labels instead of an error.

There are no other breaking changes: everything else below is additive, and you can adopt it at your own pace.

New โ€” opt in when ready

  • List & paginate conversations โ€” GET /api/conversations with status / label_id / mailbox_id filters and cursor pagination. (Previously you could only fetch one conversation at a time.)
  • Read full threads โ€” GET /api/conversations/:id/messages (paginated, oldest-first). GET /api/conversations/:id now also embeds the most recent messages and a messages_url.
  • Conversations expose a mailboxes array and can be moved with PATCH /api/conversations/:id/mailboxes. List mailboxes and their members via GET /api/mailboxes and GET /api/mailboxes/:id/members.
  • Conversation actions โ€” archive, trash, spam, snooze, and ignore (with reversals), attributed to the API token.
  • Manage labels โ€” update (PATCH) and delete (DELETE) in addition to list/create.
  • Update drafts โ€” PATCH /api/messages/:id, which respects the same edit locks as the web app (423 while a teammate is editing).
  • Autoresponder โ€” GET / PATCH /api/autoresponder.
  • Add internal comments โ€” POST /api/conversations/:id/comments, authored by the API token and surfaced to subscribers like a teammate’s comment.
  • New integrations should use the string ids returned by the API rather than numeric ids โ€” they’re stable, human-readable in logs, and consistent across resources.
  • When listing, follow next_cursor to page through results instead of assuming a single response holds everything.