# Build your own inbox

An inbox is a list of cases. A case is one conversation with one contact inside a pipeline.


**You will:** List cases, show what the agent is doing, take control and answer as your team.

**You need:**
- A key with cases:read and cases:send
- A webhook endpoint for message.created

**Case**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `case` |
| `id` | string | yes |  |
| `contactId` | string | yes |  |
| `pipelineId` | string | yes |  |
| `stageId` | string or null | yes |  |
| `agentId` | string or null | yes | The agent that answers the case: the copilot of a copilot case, otherwise the agent of the current stage. |
| `status` | enum | yes | `open`, `closed` |
| `assigneeId` | string or null | yes |  |
| `channel` | enum | yes | Where the contact talks: whatsapp, email, api (your own system) or in_app. null when the case has no channel to write back. `whatsapp`, `api`, `in_app`, `email`, `null` |
| `phoneNumberId` | string or null | yes | The WhatsApp number the contact wrote to, when it is a WhatsApp case. |
| `control` | enum | yes | Who answers the contact. human also covers a case the agent escalated and nobody took yet: see agentState. `agent`, `human` |
| `agentState` | enum | yes | What the agent is doing, to drive a typing indicator: thinking and replying mean it is working; waiting_for_contact means it answered; escalated means it asked for a person; idle is everything else. `idle`, `thinking`, `replying`, `waiting_for_contact`, `escalated` |
| `unread` | integer | yes | Messages of the contact since the last POST /v2/cases/{id}/read. |
| `lastMessageAt` | string or null | yes |  |
| `lastMessage` | LastMessage or null | yes | A preview of the last public message. |
| `contact` | CaseContact | no | The contact with its presence. Present only on GET /v2/cases and GET /v2/cases/{id}, when the organization setting presenceEnabled is on, at least one presence signal has arrived, and the key has the contacts:presence-read scope. |
| `outcome` | string or null | yes |  |
| `closedAt` | string or null | yes |  |
| `fields` | object | yes | The values of the fields the pipeline declares, by key. Sensitive fields are not included. |
| `tags` | string[] | yes | The tags of the contact of the case. |
| `archivedAt` | string or null | yes | When the case was archived: out of the board, state untouched. null when it is on the board. |
| `muted` | boolean | yes | true while the contact is muted: the agent does not answer them and their open cases are closed. Muting belongs to the person, so every case of the contact shows it. |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |

Every case points to a contact, and every message belongs to a case:

**Contact**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `contact` |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `email` | string or null | yes |  |
| `phone` | string or null | yes |  |
| `externalRef` | string or null | yes |  |
| `companyId` | string or null | yes | The company of this contact, or null when it has none. |
| `tags` | string[] | yes |  |
| `fields` | object | yes | The values of the contact fields this contact has, by key. Empty when it has none. |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |
| `presence` | Presence | no | Whether the contact is online. Present only when the organization setting presenceEnabled is on, at least one presence signal has arrived, and the key has the contacts:presence-read scope. |
| `match` | object or null | no | Only when you search with q: the field of the contact that matched and its value, or null when the match was the name, email, phone or externalRef. Fields marked sensitive are never searched or shown. |
| `caseCount` | integer | no | Only when you search, filter by pipeline or open cases, or sort: how many cases the contact has. |
| `openCaseCount` | integer | no | Same as caseCount, only the open ones. |
| `lastActivityAt` | string | no | Same as caseCount: the latest message of its cases, or its creation date. |

**Message**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `message` |
| `id` | string | yes |  |
| `caseId` | string | yes |  |
| `author` | enum | yes | `contact`, `agent`, `team` |
| `authorName` | string or null | yes |  |
| `visibility` | enum | yes | `public`, `internal` |
| `text` | string | yes |  |
| `contentType` | string | yes |  |
| `attachments` | MessageAttachment[] | yes |  |
| `interactive` | Interactive or null | no | The buttons, list, product or catalog the message carried. null on any other message. |
| `reply` | MessageReply or null | no | Set when the person tapped a button or a list row: the id and title they tapped. null otherwise. |
| `order` | MessageOrder or null | no | Set when the person sent a cart from the WhatsApp catalog. text carries their note. null otherwise. |
| `location` | MessageLocation or null | no | Set when the person shared a location (answering a location_request or on their own). text carries it as a sentence the agent reads. null otherwise. |
| `flowResponse` | MessageFlowResponse or null | no | Set when the person finished a WhatsApp Flow: the fields they filled in. null otherwise. |
| `contact` | MessageContact or null | no | Set when the person shared their phone answering a contact_request. A contact card the person forwards on their own is not kept. null otherwise. |
| `error` | MessageError or null | no | Set when status is failed: why. null on any other status. Read it to know whether to retry, send a template (outside the 24-hour window) or stop. |
| `status` | enum | yes | Delivery to the channel of the case. `pending`, `sent`, `delivered`, `read`, `failed` |
| `createdAt` | string | yes |  |

## List what needs attention

```bash title="cURL"
curl "https://api.lelemon.cl/v2/cases?status=open" \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
for await (const kase of lelemon.cases.list({ query: { status: 'open' } })) {
  console.log(kase.id, kase.control, kase.agentState, kase.unread);
}
```


Keep the list fresh with the `message.created`, `case.control_changed` and `case.escalated` webhooks instead of polling.

## Show what the agent is doing

`agentState` on the case drives a typing indicator.

| `agentState` | Meaning |
| --- | --- |
| `thinking`, `replying` | The agent is working |
| `waiting_for_contact` | The agent answered |
| `escalated` | The agent asked for a person |
| `idle` | Anything else |

`control` says who answers the contact: `agent` or `human`. `human` also covers a case the agent escalated that nobody took yet.

## Let a person answer

Take control, write as the team, and hand the case back.

```bash title="cURL"
curl -X POST https://api.lelemon.cl/v2/cases/cse_.../take-control \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: reply-cse-0043" \
  --json '{ "author": "team", "authorName": "Camila", "text": "Perdón, te devolvemos el envío. ¿Te sirve?" }'

curl -X POST https://api.lelemon.cl/v2/cases/cse_.../release-control \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
await lelemon.cases.takeControl(kase.id);
await lelemon.caseMessages.create(kase.id, {
  author: 'team',
  authorName: 'Camila',
  text: 'Perdón, te devolvemos el envío. ¿Te sirve?',
});
await lelemon.cases.releaseControl(kase.id);
```


Writing as `team` needs `cases:send`. While the agent is in control the call answers `409 case_agent_in_control`, so a contact never gets two answers at once. On WhatsApp, free text more than 24 hours after the contact's last message answers `422 channel_window_closed`; send a [template](/guides/events-and-triggers) instead.

## Send buttons, lists and products

On a WhatsApp case, `interactive` replaces `text`. The person answers with one tap, and the tap arrives as a message of the contact with `reply: { id, title }`.

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: confirm-cse-0043" \
  --json '{
    "author": "team",
    "interactive": {
      "type": "buttons",
      "body": "Do you confirm your appointment tomorrow at 10:00?",
      "buttons": [
        { "id": "appt-43-confirm", "title": "Confirm" },
        { "id": "appt-43-cancel", "title": "Cancel" }
      ]
    }
  }'
```


| `type` | Content field | Limits |
|---|---|---|
| `buttons` | `buttons` | 1 to 3 buttons, titles up to 20 characters |
| `list` | `list` | up to 10 sections and 10 rows in total, titles up to 24 characters |
| `product` | `product` | one product of the catalog connected to the number |
| `product_list` | `products` | up to 30 products in up to 10 sections, with `header` and `body` |
| `catalog` | `catalog` | opens the whole catalog |
| `cta_url` | `cta` | one button that opens an https link |
| `flow` | `flow` | a button that opens a WhatsApp Flow |
| `location_request` | none | asks the person to share a location |
| `contact_request` | none | asks the person to share their phone |
| `call` | `call` | a button that starts a WhatsApp call |
| `carousel` | `carousel` | 2 to 10 swipeable cards with an image or video |

The catalog is connected by the business in Meta Commerce Manager and WhatsApp Manager, not through the API. Save its id on the number with `catalogId`: a `product`, `product_list` or `catalog` message answers `422 catalog_not_connected` when the number has no catalog or the message names another one. `productId` is the id of the product in that catalog. The [WhatsApp guide](/guides/whatsapp) has the limits of each format and the commerce settings.

A message that breaks a limit answers `400 invalid_request` with `param` set to the field, for example `interactive.buttons`. Send `interactive` or `text`, never both. Interactive messages follow the same rules as free text: `cases:send`, a person in control and the 24 hours window. On a case that is not on WhatsApp the answer is `422 interactive_unsupported_channel`.

When the person sends a cart from the catalog, it arrives as a message with `order: { catalogId, items }`, and `message.received` carries `type: "order"`. A shared location arrives as `location`, the answer of a Flow as `flowResponse` and a phone shared for a `contact_request` as `contact`; the event carries only their `type` (`location`, `flow_response`, `contact`) and you read the data in the message.

A message whose `status` is `failed` has `error: { code, message }` with the reason: WhatsApp's `code`, or `null` when the platform stopped it before sending.

## Leave a private note

A message with `"visibility": "internal"` is a note only your team sees. It never reaches the contact or the agent and needs only `cases:write`.

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "author": "team", "visibility": "internal", "text": "Frequent customer, offer a free drink." }'
```


A note signs with the name of the API key. Send `authorName` to sign it with the person who wrote it:

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../notes \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "content": "Called, no answer.", "authorName": "Ana Pérez" }'
```


A message from the contact (`"author": "contact"`) in a stage without an agent is saved when a person has taken control of the case. If nobody has, it answers `422`, because nobody would answer. `POST /v2/cases` with only `agentId` answers `422` on `pipelineId` when that agent attends the entry stage of more than one pipeline: send `pipelineId` or `stageId` too. `case.control_changed` carries `contactId` and `externalRef`.

## Tag, fill and reveal

`tags` and `fields` change with `PATCH /v2/cases/{id}`. Tags replace the whole list and belong to the contact, so the contact and its other cases show the same ones. `fields` writes the fields the pipeline declares, whoever fills them (the team, the channel, your system or the agent). A key the pipeline does not declare answers `400 invalid_request` with `param: "fields"` and nothing is saved.

```bash title="cURL"
curl -X PATCH https://api.lelemon.cl/v2/cases/cse_... \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "tags": ["vip", "rm"], "fields": { "bank_account": "000123456" } }'
```

```ts title="Node.js"
await lelemon.cases.update(kase.id, {
  tags: ['vip', 'rm'],
  fields: { bank_account: '000123456' },
});
```


A field marked `sensitive` is written but never read back: the case, the webhooks and the agent do not carry it. To see it, reveal it with a key that has `cases:reveal`, a scope of its own that `cases:read` and `cases:write` do not include.

```bash title="cURL"
curl -X POST https://api.lelemon.cl/v2/cases/cse_.../fields/bank_account/reveal \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const { value } = await lelemon.cases.fields.reveal(kase.id, 'bank_account');
```


Every reveal writes who looked, the case and the field into an audit trail before the value is returned. The trail never holds the value. The response is not kept for an idempotent replay and not in the request log, so a retry reveals again and is audited again. A field that is not sensitive, or that the pipeline does not declare, answers `422 field_not_sensitive`. It is the same audit trail the dashboard writes to when a person reveals a field.

## Pin a note

`PATCH /v2/cases/{id}/notes/{noteId}` takes `pinned`. A case has one pinned note, so pinning one unpins the one that was pinned. Send `content`, `pinned` or both.

```bash title="cURL"
curl -X PATCH https://api.lelemon.cl/v2/cases/cse_.../notes/not_... \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "pinned": true }'
```

```ts title="Node.js"
await lelemon.caseNotes.update(kase.id, note.id, { pinned: true });
```


## Archive, move and change pipeline

`archive` takes a case off the board without deleting it or changing its status, and `unarchive` brings it back. Both are safe to repeat. The case carries `archivedAt`, and `GET /v2/cases?archived=true` lists the archived ones.

`move` takes `stageId` or `pipelineId`, never both. With `pipelineId` the case goes to the entry stage of that pipeline, with the same checks as a move to a stage of another pipeline.

`POST /v2/cases/move-batch` moves up to 100 cases of one pipeline to a stage of that pipeline. It does not fail as a whole: the ones that can move move, and `notMoved` gives the reason for each of the others.

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/move-batch \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: batch-2026-11-01" \
  --json '{ "caseIds": ["cse_...", "cse_..."], "stageId": "stg_..." }'
```

```ts title="Node.js"
const { moved, notMoved } = await lelemon.cases.moveBatch({
  caseIds: [first.id, second.id],
  stageId: stage.id,
});
```


| `reason` | Meaning |
| --- | --- |
| `not_found` | No such case in your organization |
| `not_in_pipeline` | The case is in another pipeline. Use `move` with `pipelineId` |
| `archived`, `closed` | The case cannot move |
| `already_in_stage` | Nothing to do |
| `required_fields_missing` | The stage needs fields the case lacks. `missingFields` has their keys |

## Mute and hand to a person

`mute` silences the contact of the case: the agent stops answering and every open case of the contact closes. Muting belongs to the contact, so every case of the person shows `muted: true` until `unmute`. The cases it closed stay closed.

`escalate` marks the case as needing a person, with a `reason` your team reads. It fires `case.control_changed` with `escalated: true`, and the case answers `control: "human"`. `case.escalated` is the event of an agent handing a case over, so it does not fire here. A closed case answers `409`.

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../escalate \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "reason": "The person asked to talk to someone" }'

curl https://api.lelemon.cl/v2/cases/cse_.../mute \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "reason": "Sends spam" }'
```

```ts title="Node.js"
await lelemon.cases.escalate(kase.id, { reason: 'The person asked to talk to someone' });
await lelemon.cases.mute(kase.id, { reason: 'Sends spam' });
await lelemon.cases.unmute(kase.id);
```


## Read the history of a person

The cases of a person are `GET /v2/cases?contactId=ctc_...`, and the messages of each one are in `GET /v2/cases/{id}/messages`. The events you reported for the person are `GET /v2/contacts/{id}/events`, newest first, with what each one sent or why it did not.

## Remind your team

A reminder is a task for a member, due at a time. The assignee sees it in their tasks in the dashboard and gets the notice when it is due, like one a person created there.

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../reminders \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "title": "Call back about the quote",
    "type": "call",
    "priority": "high",
    "dueAt": "2030-01-15T14:00:00.000Z",
    "assigneeId": "mbr_..."
  }'

curl https://api.lelemon.cl/v2/reminders/rem_.../complete \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "completionNote": "Talked to them." }'
```

```ts title="Node.js"
const reminder = await lelemon.caseReminders.create(kase.id, {
  title: 'Call back about the quote',
  type: 'call',
  priority: 'high',
  dueAt: '2030-01-15T14:00:00.000Z',
  assigneeId: member.id,
});
await lelemon.reminders.complete(reminder.id, { completionNote: 'Talked to them.' });
```


`GET /v2/cases/{id}/reminders?status=active` lists the ones still to do. `assigneeId` has to be an active member (`400` on `assigneeId` otherwise). `PATCH /v2/reminders/{id}` edits it, `dismiss` drops it but keeps it in the list, and `DELETE` removes it. Creating and editing need `cases:write`, reading needs `cases:read`.

## Other case actions

`move` changes the stage or the pipeline, `assign` sets the assignee (take the `mbr_` id from `GET /v2/members`, which needs `members:read`), `close` and `reopen` change the status, and `read` clears `unread`. Attachments are uploaded to `/v2/cases/{id}/attachments` and sent by passing `attachmentIds` in a message.
