๐Ÿ‘‘ 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

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

Labels

List Labels

Get all labels for your team, sorted alphabetically.

GET /api/labels

Response:

[
  {
    "id": 1,
    "name": "Bug",
    "color": "cherry"
  },
  {
    "id": 2,
    "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:

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

Success response:

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

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.

Conversation Labels

Add a Label to a Conversation

Apply an existing label to a conversation. The conversation_id is the conversation’s slug (the URL-friendly identifier you see in the address bar).

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:

{
  "label_id": 456
}

Success response (201 Created):

Returns all labels currently on the conversation.

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

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:

{ "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.

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 assign 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 a specific message 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:

{
  "body": "<p>Thanks for reaching out! We'll look into this right away.</p>",
  "member_id": 42
}

Success response (201 Created):

{
  "id": "123",
  "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"
  }
}

If a draft reply already exists for that message, the endpoint returns 409 Conflict with the existing draft in the response body. This prevents accidentally overwriting a draft that a team member may have already edited.

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.

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"]}
500 Server error {"error": "Internal server error"}

Examples

curl

# List labels
curl "https://app.letsjelly.com/api/labels" \
  -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": 123}'

# 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}'

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

# 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();
}

// 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();
}

// 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();
}

Rate Limits

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