> ## Documentation Index
> Fetch the complete documentation index at: https://developers.sapot.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions & Gotchas

> The cross-cutting rules that trip up MCP integrations: the account header, display-id vs DB-id, label semantics, epoch timestamps, pagination, and custom-attribute merges.

A short checklist of the rules that span every domain. Most integration surprises come from one of the seven items below — skim them once and they stop biting.

## The account is fixed by the connection

Every MCP call must carry the `X-Sapot-Account-Id` header. It is set once, on the connection, and Rails independently enforces that your token is bound to that exact account — a mismatch is rejected with *"Access token is not valid for this account"*.

```bash theme={null}
claude mcp add --transport http sapot https://mcp.sapot.ai/mcp --header "Authorization: Bearer YOUR_AGENT_KEY" --header "X-Sapot-Account-Id: YOUR_ACCOUNT_ID"
```

<Warning>
  The account id is **never** a tool argument. No tool accepts an `account_id` parameter — passing one is impossible, and there is no way to reach another account with the same key. One key, one account.
</Warning>

## Display id vs database id

Sapot Chat uses two different id spaces, and mixing them is the most common mistake. Conversations and campaigns are addressed by their **per-account display id** (the number you see in the dashboard URL); contacts are addressed by their **database id**.

| You have…      | Use this id                                            | Tools                                                                                            |
| -------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| A conversation | Display id (per-account number in the UI)              | `get_conversation`, `reply_to_conversation`, `assign_conversation`, `set_conversation_status`, … |
| A campaign     | Display id (per-account number, from `list_campaigns`) | `get_campaign`, `update_campaign_draft`                                                          |
| A contact      | Database id (the `id` on a list/search result)         | `update_contact`, `add_contact_labels`                                                           |

<Tip>
  A contact's `id` from `list_contacts` / `search_contacts` is its database id — feed that straight into `update_contact`. Do not use a conversation display id as a contact id.
</Tip>

## Labels: two separate gotchas

**1. Setting labels REPLACES the whole set.** `add_conversation_labels` and `add_contact_labels` are not additive despite the name — they overwrite the record's entire label list with exactly what you send. To add one label, read the current labels first and send the **union**.

**2. Titles here, numeric ids there.** The two paths take different label representations:

| Where                                           | Label form                                   | How to get it                     |
| ----------------------------------------------- | -------------------------------------------- | --------------------------------- |
| Setting/filtering conversation & contact labels | **Title strings** (e.g. `["vip", "refund"]`) | you already know them             |
| Campaign audiences & audience preview           | **Numeric label ids** (e.g. `[3, 8]`)        | `list_labels` → each label's `id` |

Passing a title string where a numeric id is expected (`preview_campaign_audience`, `draft_campaign`, `update_campaign_draft`) silently yields a **zero-reach audience** — always resolve ids with `list_labels` first.

## Reports use UNIX epoch seconds

The `since` / `until` arguments on report tools are **UNIX epoch seconds as strings** (e.g. `"1719792000"`), not ISO date strings. This applies to `get_report_summary`, `get_report_timeseries`, and `get_entity_summary`.

## Pagination differs by domain

Page size is fixed per domain — there is no `per_page` argument. Advance with the `page` argument (1-based) except on messages, which are cursor-paginated by message id.

| Domain                | Page size     | Advance with                                   |
| --------------------- | ------------- | ---------------------------------------------- |
| Conversations         | 25 / page     | `page`                                         |
| Contacts              | 15 / page     | `page`                                         |
| Knowledge entries     | 25 / page     | `page`                                         |
| Conversation messages | \~20 (newest) | `before` (older) / `after` (up to \~100 newer) |

## custom\_attributes are MERGED on update

`update_contact` merges the `custom_attributes` you send into the existing ones — keys you omit are **preserved**, not wiped. Send only the keys you want to change; you do not need to read-modify-write the whole object. (This is the opposite of the label tools above, which replace.)

<Note>
  The same "only the fields you pass are changed" rule holds for `update_contact`, `update_knowledge_entry`, `update_knowledge_profile`, and `update_campaign_draft`. `custom_attributes` is the one field that deep-merges rather than replaces.
</Note>

## See also

<CardGroup cols={2}>
  <Card title="Authentication & Scopes" icon="key" href="/ai-agents/authentication">
    Default-deny scopes, the 401 scope hint, and the 429 quota response.
  </Card>

  <Card title="REST API" icon="book" href="/api-reference/introduction">
    Call the same endpoints directly with the same scoped key.
  </Card>
</CardGroup>
