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

# Intercom

> Surface an Intercom workspace (conversations, tickets, contacts, companies, teammates, teams) as read-only Forest collections

The Intercom datasource surfaces an [Intercom](https://www.intercom.com) workspace as Forest collections. It exposes conversations, tickets, contacts, companies, teammates, teams, ticket types and ticket states on top of the [Intercom REST API](https://developers.intercom.com/docs/references/rest-api/api.intercom.io/), with server-side filtering, free-text search, relations across the whole support graph, the conversation and ticket threads, and the custom attributes your workspace defines.

> [!WARNING]
>
> The Intercom datasource is only available for Ruby (gem `forest_admin_datasource_intercom`). There is no Node.js equivalent yet.

> [!NOTE]
>
> **This version is read-only.** Every collection and every column is published read-only: no create, no update, no delete, and no action.
>
> It also **refuses rather than approximates**. Where Forest asks for something Intercom's API has no equivalent for, the datasource answers a `400` naming what to change instead of returning a page that looks filtered, sorted or counted and is not.

## Installation

Install the gem `forest_admin_datasource_intercom`.

```ruby theme={null}
# Gemfile
gem 'forest_admin_datasource_intercom'
```

```ruby theme={null}
# app/lib/forest_admin_rails/create_agent.rb
module ForestAdminRails
  class CreateAgent
    def self.setup!
      datasource = ForestAdminDatasourceIntercom::Datasource.new(
        access_token: ENV['INTERCOM_ACCESS_TOKEN'],
        region: :eu # :us (default), :eu or :au
      )

      @create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource, {})
      customize
      @create_agent.build
    end
  end
end
```

The token is the access token of a private app, created in Intercom's Developer Hub under *Configure › Authentication*. OAuth is not supported.

## Configuration

`access_token` is mandatory; the datasource fails fast with a `ForestAdminDatasourceIntercom::ConfigurationError` when it is missing or blank. Everything else is optional and only exists to trade regions, timeouts and rate-limit behaviour.

| Option | Default | Description |
| - | - | - |
| `access_token` | — | **Required.** The private app's bearer token. |
| `region` | `:us` | `:us`, `:eu` or `:au`. A workspace answers in its own region only. |
| `base_url` | derived from `region` | Wins over `region`. For a gateway, or a mock server. A base URL mounted under a subpath is supported. Refused if it carries credentials, see below. |
| `api_version` | `'2.16'` | Sent as the `Intercom-Version` header on every request. |
| `open_timeout` | `5` | Connection timeout, in seconds, for the requests the running Forest back-end makes. |
| `timeout` | `30` | Read timeout, in seconds, for the requests the running Forest back-end makes. |
| `retry_policy` | `RetryPolicy.new` | How a failed request is retried. See [Rate limits and retries](#rate-limits-and-retries). |
| `rate_limiter` | `RateLimiter.new` | Proactive throttling driven by Intercom's own rate-limit headers. Pass `nil` to leave the 429 retry as the only rate-limit handling. |
| `boot_open_timeout` | `3` | Connection timeout for the four reads performed while the datasource is being built. |
| `boot_timeout` | `10` | Read timeout for those same reads. |
| `boot_retry_policy` | `RetryPolicy.boot` | Retry policy for those same reads: one quick retry, since they sit in front of a Rails boot. |
| `reference_cache_ttl` | `60` | Seconds the workspace's reference lists are held for. `0` re-reads them on every request. See [What a page costs](#what-a-page-costs-and-what-is-held). |
| `adapter` | persistent, 25 connections | The Faraday adapter. `:net_http` opts out of keep-alive; `[:net_http_persistent, { pool_size: 50 }]` sizes the pool. See [What a page costs](#what-a-page-costs-and-what-is-held). |
| `concurrent_reads` | `1` | How many records a read by id fetches at once. Every read in flight holds a connection of the pool. See [Reading records by id in parallel](#reading-records-by-id-in-parallel). |

**A `base_url` carrying credentials is refused at boot.** Faraday would turn `https://user:pass@proxy.internal` into a Basic `Authorization` header, which the Intercom token then replaces, so the credentials would be dropped on every request without a word. A credentialed egress proxy goes in `HTTPS_PROXY=https://user:password@proxy:port` instead, which Faraday sends as `Proxy-Authorization`.

> [!NOTE]
>
> The access token never reaches an `inspect`: `Configuration`, `Client` and `Datasource` all mask it, and the base URL is printed with its user-info and its query string redacted, the query string being where a gateway usually takes its key.

**Pin the region explicitly.** `api.intercom.io` does route to the right one, but a workspace under GDPR wants its requests reaching the European host and nothing else.

**The API version is pinned on purpose.** Without the header a request follows the workspace's own default version, which a workspace admin can change on Intercom's side — and the payloads change shape underneath. Intercom echoes the version it served, so the boot compares the two and logs a warning when the pin was not honoured rather than raising.

### Token permissions

A read-only token is enough, and is what to use for this version. A permission the token lacks costs **columns or a collection, never the boot**: a boot-time introspection answered `403` degrades to no attribute column, a collection whose endpoint answers `403` fails its own page, and a token that cannot read `/admins` or `/teams` leaves the `admin_names` / `team_names` columns empty rather than failing the page they are on. A token denied contacts or companies costs those two collections and the `contact_name` column, and leaves everything else standing.

> [!WARNING]
>
> **A relation is the exception**: resolving one reads the target endpoint, and that read is not guarded the way the denormalized name columns are. A token denied `/admins` fails any page projecting `admin_assignee:name`, the related lists behind `IntercomTeam.admins` and `IntercomAdmin.teams`, and `IntercomTeamMembership` itself — the failure lands on the collection being read, not on the one that was denied.
>
> Scope the token to the endpoints in the [collections table](#provided-collections), or to none of them.

## Provided collections

Once the datasource is registered, nine collections are added to your Forest project. Every one of them is read-only.

| Collection | Endpoints | Paginated | Countable |
| - | - | - | - |
| `IntercomConversation` | `GET /conversations`, `POST /conversations/search`, `GET /conversations/{id}` | cursor | yes, exactly |
| `IntercomTicket` | `POST /tickets/search`, `GET /tickets/{id}` | cursor | yes, exactly |
| `IntercomContact` | `GET /contacts`, `POST /contacts/search`, `GET /companies/{id}/contacts` | cursor | yes, exactly |
| `IntercomCompany` | `POST /companies/list`, `GET /companies?...`, `GET /companies/{id}` | **offset** | yes, exactly |
| `IntercomAdmin` | `GET /admins` | read whole | yes, exactly |
| `IntercomTeam` | `GET /teams` | read whole | yes, exactly |
| `IntercomTeamMembership` | `GET /teams`, `GET /admins` | read whole | yes, exactly |
| `IntercomTicketType` | `GET /ticket_types` | read whole | yes, exactly |
| `IntercomTicketState` | `GET /ticket_states` | read whole | yes, exactly |

Three tiers, and they behave differently on purpose.

**Read whole** — teammates, teams, team memberships, ticket types, ticket states. Their endpoints answer in one response, so filtering, sorting, paging and counting them in memory is *exact*: the records in hand are every record Intercom holds. These are the only collections that can be grouped, and the only ones a chart may group by. The cost is bandwidth, not correctness.

**Cursor** — conversations, tickets and contacts. What is in hand is a page of something far larger, so nothing is filtered or sorted in memory. No condition walks the listing endpoint, `id equals X` reads the record through its own endpoint, a contact's `company_id equals X` reads the account's contacts, and anything else is translated into Intercom's search DSL. What the translation cannot express is [refused by name](#filters).

**Offset** — companies, and nothing else. `POST /companies/list` takes a **page number**, which is what a list view asks for: page 7 is one request rather than six pages walked to reach it. What it pays for that is filtering — there is no company search endpoint at all, so a filter on a company is a handful of exact lookups and nothing else. See [Companies](#companies).

### Relationships

Intercom joins nothing: a ticket carries an assignee id, and the teammate behind it is a second read of a second endpoint. The following relationships are exposed automatically:

| Collection | Relation | Target | Filterable through |
| - | - | - | - |
| `IntercomConversation` | `admin_assignee`, `closed_by` | `IntercomAdmin` | yes |
| `IntercomConversation` | `team_assignee` | `IntercomTeam` | yes |
| `IntercomConversation` | `contact` | `IntercomContact` | yes |
| `IntercomTicket` | `admin_assignee` | `IntercomAdmin` | yes |
| `IntercomTicket` | `team_assignee` | `IntercomTeam` | yes |
| `IntercomTicket` | `ticket_type` | `IntercomTicketType` | yes |
| `IntercomTicket` | `state`, `previous_state` | `IntercomTicketState` | **no** — read and navigate only |
| `IntercomTicket` | `contact` | `IntercomContact` | unconfirmed, see below |
| `IntercomContact` | `owner` | `IntercomAdmin` | yes |
| `IntercomContact` | `company` | `IntercomCompany` | **no** — read and navigate only |
| `IntercomContact` | `conversations`, `tickets` | `IntercomConversation`, `IntercomTicket` | no (one-to-many) |
| `IntercomCompany` | `contacts` | `IntercomContact` | no (one-to-many) |
| `IntercomTeam` | `admins` | `IntercomAdmin` | no (many-to-many) |
| `IntercomAdmin` | `teams` | `IntercomTeam` | no (many-to-many) |
| `IntercomTeamMembership` | `team`, `admin` | `IntercomTeam`, `IntercomAdmin` | yes |

**The full support graph is those contact rows.** From a ticket or a conversation, `contact` reaches the person who wrote in; from them, `conversations` and `tickets` list everything they ever opened, and `company` reaches their account, whose `contacts` lists their colleagues. Each of those lists is one request.

Two caveats. Whether `/tickets/search` filters on a contact id **is unmeasured**: the relation is navigable either way, and if the endpoint does not filter it the condition moves to the refusal list. And the **company traversal is not offered**: `/contacts/search` filters no company field, so the relation is published unfilterable and the Forest back-end refuses `company:name` on a contact before it reaches this datasource. Filter from the company side instead: open the account and read its `contacts`.

**A conversation has several contacts, and the relation names the first of them** — the same one the `contact_name` column describes, next to a `contact_count`, so the column and the relation cannot disagree. The others are a hop away: open that contact and read their conversations.

> [!NOTE]
>
> A relation reads its target **undecorated**, so a permission scope or a segment defined on the target does not narrow what a relation resolves — the same way a native datasource joins a table without applying the scopes of the collection mapped to it.
>
> And **a row whose foreign key is null matches no relation filter**, the way a join drops it, negated filters included. A ticket with no assignee is not "assigned to someone other than Marie".

#### What a relation costs

What makes these relations affordable is what the collection on the far end costs to read by id, and the three tiers do not cost the same:

| Target | Read by id | A page of rows costs | Fan-out |
| - | - | - | - |
| `IntercomAdmin`, `IntercomTeam`, `IntercomTicketState`, `IntercomTicketType` | read whole | **one request**, whatever the page holds | unbounded |
| `IntercomContact` | `id IN [...]`, 15 at a time | one request per 15 distinct contacts | unbounded |
| `IntercomCompany` | `GET /companies/{id}` | **one request per distinct account** | bounded, see below |

The price is per target *collection*, not per relation: a ticket's `state` and `previous_state` are one read of `/ticket_states`, over the ids both of them name.

> [!WARNING]
>
> **The one relation with a ceiling is `IntercomContact.company`.** There is no bulk read for a company — `/companies/scroll` is [deliberately rejected](#companies) — so each distinct account on the page costs a request, issued one after another unless [`concurrent_reads`](#reading-records-by-id-in-parallel) is raised. Past **100** distinct accounts the read is **refused by name** rather than resolved for the first slice and left `nil` for the rest.
>
> Every page size a list view offers sits under that figure. An export, which batches a thousand rows at a time, or a segment resolved whole, does not — and the message says to leave the column out or read fewer rows at a time.

"Read whole" is what the endpoint answers, with one more bound: the read stops after **10** pages should Intercom ever paginate one of these on its own, logs when it does, and does not cache the partial list. A workspace whose `/admins` or `/teams` runs past that cap resolves the relations pointing at the records it dropped as empty.

#### Team membership

**`IntercomTeamMembership` exists because Intercom's does not.** The workspace carries the membership on the team (`admin_ids`) and on the teammate (`team_ids`) both, and exposes no resource for the pair — while a many-to-many needs a collection to travel through. It is synthesized from `GET /teams` and `GET /admins`, one record per pair, keyed `teamId:adminId`.

Two consequences of travelling through it are visible in the panel:

* a **related list of teammates is ordered by the membership, not by the teammate**: the Forest back-end hands the through collection the columns of the collection the relation reaches, so an order on `name` or `email` cannot be resolved there and is logged rather than silently dropped;
* an `admin_ids` entry naming a teammate `/admins` does not answer — one who left, one outside the token's reach — **drops out of the related list and of its count** instead of appearing as an empty row.

Alongside it, a team names its teammates (`admin_names`) and a teammate its teams (`team_names`) on the row itself, so a list view reads without a join. They are read only when a projection asks for them, and a token that cannot read the other side leaves the column empty — unknown, not an empty list — without failing the page. The relations themselves do need that read, see [Token permissions](#token-permissions).

### Conversation and ticket threads

Both `IntercomConversation` and `IntercomTicket` carry a structured `timeline` column: who said what, when, and through which kind of event. Each entry has the following shape:

| Field | Type | Source |
| - | - | - |
| `id` | `String` | Intercom part id |
| `part_type` | `String` | The kind of event — a reply, an assignment, a note, a state change |
| `created_at` | `Date` | Event timestamp |
| `author_type` | `String` | `admin`, `user`, `bot`… |
| `author_name` | `String` | Author, flattened from Intercom's nested author object |
| `author_email` | `String` | Same |
| `body` | `String` | Message body, read as plain text |
| `attachment_count` | `Number` | How many files ride along |
| `redacted` | `Boolean` | Whether Intercom redacted the part |

`part_type` is kept on every entry on purpose: an assignment, an internal note and a reply are not the same event, and a thread that flattens them reads as an exchange that never happened the way it did. The team's internal notes are therefore in the thread — see [Privacy](#privacy).

The two collections differ in what a thread costs:

* **A ticket's thread is free.** Intercom returns the parts inside the search response whether or not anything asks for them, so there is no request per row and no cap. An empty list means an empty thread; a payload carrying no parts at all leaves the column empty, for *unknown*.
* **A conversation's thread is not.** Intercom returns the parts only when retrieving a single conversation, so a record detail gets its timeline for free while a list view asking for the column pays one request per row, bounded to **10** rows and [run in parallel](#reading-records-by-id-in-parallel) when `concurrent_reads` allows it. The rows past that are left empty, which reads as *unknown* — never as an empty thread.

**The conversation timeline opens on `source`, not on the parts.** The message that started the conversation lives there; a thread built from the parts alone opens on the first reply and loses what the customer actually asked.

> [!NOTE]
>
> Intercom keeps the **500 most recent parts** of a conversation or a ticket. A very long thread is therefore partial; a page of tickets that reaches the ceiling says so in the log, and it is the same truncation that can hide a ticket's [derived closure date](#tickets).

### Custom attributes

The attributes your workspace defines are introspected at boot and published as columns, typed from Intercom's own declaration:

* `IntercomTicket` carries the attributes declared on its ticket types, read from `GET /ticket_types` and published as the **union** of every type's, keyed by name the way the payload is;
* `IntercomContact` and `IntercomCompany` carry theirs, read from `GET /data_attributes?model=contact` and `?model=company`.

All of them are **display-only**: they are neither filterable, sortable nor groupable, for two different reasons spelled out under [what is not filterable](#what-is-not-filterable-and-why). A payload carries the values of the attributes that record happens to have been given, never their definitions, which is why they have to be read at boot.

**Two ticket types declaring the same attribute name with different data types share one column**, typed after the first. A ticket of the other type reads that column as empty rather than showing a value under a type it does not have, and the conflict is logged at boot. Give the two attributes different names in Intercom to publish both. A malformed entry in `/ticket_types` is skipped, and the other types keep their columns.

## Capabilities

Intercom is a support API, not a database, and several things Forest asks for have no equivalent. Where that happens the datasource **refuses with a message naming the reason** rather than answering something that looks right and is not. All of these reach the user as a `400` carrying that text, and a refusal costs no request: it is raised before anything leaves the process.

### Filters

`POST /conversations/search`, `POST /tickets/search` and `POST /contacts/search` answer the condition trees Forest sends, on the fields Intercom really filters and with the operators each endpoint really validates.

#### What is filterable

| Collection | Filterable on |
| - | - |
| `IntercomConversation` | `id`, `state`, `priority`, `open`, `read`, `title`, `admin_assignee_id`, `team_assignee_id`, `source_type`, `source_subject`, `source_body`, `source_delivered_as`, `source_author_email`, `closed_by_id`, `reopen_count`, `part_count`, `ai_agent_participated`, `contact_id`, and the dates `created_at`, `updated_at`, `waiting_since`, `snoozed_until`, `closed_at`, `first_closed_at`, `first_contact_reply_at`, `last_contact_reply_at`, `last_admin_reply_at` |
| `IntercomTicket` | `id`, `open`, `category`, `ticket_type_id`, `admin_assignee_id`, `team_assignee_id`, `contact_id`, `created_at`, `updated_at` |
| `IntercomContact` | `id`, `role`, `name`, `email`, `email_domain`, `phone`, `external_id`, `owner_id`, `unsubscribed_from_emails`, `has_hard_bounced`, `marked_email_as_spam`, `language_override`, `browser`, `browser_language`, `os`, `location_country`, `location_region`, `location_city`, `company_id` (`equals` alone, see [Contacts](#contacts)), and the dates `created_at`, `updated_at`, `signed_up_at`, `last_seen_at`, `last_contacted_at`, `last_replied_at`, `last_email_opened_at`, `last_email_clicked_at` |
| `IntercomCompany` | `id`, `company_id`, `name` — exact lookups and no search endpoint, see [Companies](#companies) |

The collections read whole — teammates, teams, team memberships, ticket types, ticket states — filter in memory over every record Intercom holds, so every scalar column carries the operators the Forest back-end can evaluate there, exactly.

**The primary key** is filterable like any other column, but a filter naming it *alone* is not answered by a search: `id equals X` and `id in [...]` read the record endpoint directly. Contacts read fifteen ids per request and up to **300** per filter; the other collections read one record per id, up to **25**. Past that, the read — and its count — is **refused** rather than answered with the first slice. A list view never reaches it, since the ids are cut to the page first; an export or an API call naming more ids at once does. The search answers the key only when something else is filtered alongside it — a permission scope, a segment, or a second filter.

**A ticket's `category`** is filtered on the label the ticket shows — `Customer`, `Back-office`, `Tracker` — and sent as the word `/tickets/search` takes for it: `request`, `task`, `tracker`. Any other value is refused rather than sent.

#### Where the table comes from, and the probe

The fields a search endpoint filters are not the fields its specification lists: `/tickets/search` refuses `company_id` with `invalid_field` although every ticket carries one. So the source of truth is a table shipped with the gem, one row per column, each carrying its provenance — `measured` (observed against a real workspace) or `spec` (read off Intercom's documentation). Every filter a column publishes is **derived** from that table, so a column cannot advertise a filter the translator would then refuse.

To check the table against your own workspace, run the probe. It ships with the gem, so `bundle install` puts it on the path of the application the datasource is mounted in. Pass the same region as the datasource — the probe defaults to `us`:

```bash theme={null}
INTERCOM_ACCESS_TOKEN=... bundle exec forest_admin_intercom_probe --region eu --endpoint tickets --out measured.yml
```

It sends one search per (field, operator) cell, reads Intercom's refusal codes — `invalid_field` for a field the endpoint does not filter, `data_invalid` for an operator it refuses on that field — and reports what the table promises that Intercom refuses, plus what Intercom accepts that the table does not know about. Only a `400` or a `422` counts as a measurement: a `401`, a `429` past the retries, a `5xx` or a timeout stops the run before it writes anything.

The rows worth checking first:

1. **`admin_assignee_id` and `team_assignee_id`**, on both search endpoints — they carry the `admin_assignee` and `team_assignee` relations;
2. **the state of a ticket** — the table carries no filter on a state id, which is what keeps the `state` relation read-only;
3. **the contact id on `/tickets/search`** — the contact relation of a ticket rests on it;
4. **the operators Intercom answers on a contact or company custom attribute**, per data type, which is what keeps those columns display-only.

#### A date filter is day-granular, and the day is the UTC one

Intercom truncates a date search to the day, at the **UTC** boundary — measured, and against its own documentation, which promises the workspace's timezone. `> V` answers from the start of the day *after* V; `< V` answers before the start of V's own day.

Sent as they come, the two bounds of an interval cancel each other out: `today` reaches the datasource as `> 00:00` of the day and `< 00:00` of the next, which Intercom reads as "from tomorrow" *and* "before tomorrow" — no rows at all. So each bound is moved to the boundary that makes Intercom answer the day the filter named. What follows:

* a bound naming **a time inside a day is refused**, naming the column and the instant: answered for the whole day, it would return rows outside the window. That rules out `future`, `after_x_hours_ago`, `before_x_hours_ago`, and `before` / `after` given a time of day;
* one exception, the upper bound **at the present** that `past` and the `*_to_date` family end on: widened to midnight it only adds rows dated after now, which no filterable date holds but `snoozed_until` — refused there like the others;
* a user in UTC gets exactly the day they asked for; a user in another timezone gets the UTC days their window overlaps — up to a day wider at each end — and the Forest back-end logs that once per filter;
* a date column publishes `>` and `<` only, and no equality. `before`, `after`, `today`, `yesterday`, `past` and the whole `previous_*` family are rewritten by the Forest back-end into a pair of those bounds.

`/contacts/search` is narrower still: it refuses `>=`, `<=` and `!=` on a date where the other two endpoints take them. Nothing visible is lost, since a date column publishes the two bounds alone everywhere in this datasource.

#### What is not filterable, and why

* **every column of a company but two.** There is no `/companies/search`: Intercom looks a company up by `name` or by `company_id`, one exact value at a time. Everything else — the industry, the plan, the monthly spend — is refused by name;
* **a contact's `company_count`, `avatar` and `session_count`**, which the endpoint does not filter, and **its `company_id` beyond a bare `equals`** — answered by the account endpoint, see [Contacts](#contacts);
* **a set of ids larger than one read fetches**, listed or counted — 25, or 300 on contacts. See the primary key above;
* **a list of more than 15 values** in an `in` or a `not in` on the search endpoints, anywhere except a filter on the primary key alone: Intercom refuses the query with a `400` that names neither the field nor the condition;
* **the custom attributes of a contact or a company.** Which operators Intercom answers on each data type has not been measured, and this datasource publishes no filter it has not seen work. The probe is what turns that around;
* **the ticket attributes.** Intercom filters an attribute by id, and the same attribute carries a different id from one ticket type to the next. A union column has no single id to translate to;
* **the columns a ticket derives from its parts** — `closed_at`, `closed_by_name`, `last_reply_at`, `last_responder_name`, `last_responder_type`. They exist nowhere in Intercom;
* **the account of a ticket** — `company_id`, refused by the endpoint itself with `invalid_field`;
* **the state of a ticket** — `state_id`, `previous_state_id` and the `state` relation are read and navigated rather than filtered;
* **the tag names, the company name and the contact identity of a conversation** — read from somewhere the search endpoint does not filter, or filtered by an id the column does not hold;
* **absence, on the search endpoints.** `present`, `blank` and `missing` are rewritten by the Forest back-end into a comparison with an empty value, and Intercom's search has no operator for the lack of a value, so the condition is refused. The collections read whole do publish `present` and `blank` on their String and Boolean columns, evaluated in memory;
* **group-by**, on the cursor and offset collections. See [Aggregations](#aggregations).

#### Free-text search

The search bar is answered on two collections, each on the one column its endpoint matches text on:

| Collection | Searched on |
| - | - |
| `IntercomConversation` | `source.body` — the message that opened the conversation |
| `IntercomContact` | `email` |

Intercom matches **per word, not as a substring**: searching `fact` does not find `facture`, and searching `acme` does not find `camille@acme.test`. `IntercomTicket` exposes no text column its endpoint matches and refuses a search by name; `IntercomCompany` has no search endpoint at all and refuses one the same way, naming the lookups it does answer.

Both still show a search bar, so a search typed there — on the list, on a related list, or in Forest's search across collections — comes back as that refusal rather than as an empty page. To remove the bar, disable search on the collection in your customization:

```ruby theme={null}
@create_agent.customize_collection('IntercomTicket') do |collection|
  collection.disable_search
end
```

#### Through a relation

A relation is published filterable as soon as *any* column of its target is, so the interface offers `admin_assignee:name` the moment the relation exists — unless the endpoint filters nothing on the foreign key. `IntercomTicket.state`, `IntercomTicket.previous_state` and `IntercomContact.company` are published unfilterable for that reason, and the Forest back-end refuses a condition through them before it reaches this datasource. What Intercom is really filtered on is the foreign key: the **target says which of its records match**, over every record it holds rather than over a page, and the ids it names become the condition the search carries.

That is exact, and it has three visible edges:

* **The target is read one record past what a group may hold, and no further.** Against a collection read whole that costs nothing, but contacts are a page of something far larger, so the read is bounded, and the refusal says "more than fifteen" rather than a count it did not measure.
* Intercom takes no membership operator on these fields, so several matches become **one equality per match**, inside an `OR` — which counts against the fifteen conditions a group allows. A relation condition matching more records than that is refused by name.
* A condition the target matched **no record** with is answered with no rows, without sending a search that would come back with everything.

On the collections read whole the same condition costs nothing: they filter in memory, so the ids go in as a plain membership and none of the DSL's limits apply.

A **many-to-many is published unfilterable** — `admins` and `teams` — and a condition written on one anyway, in a scope or a segment, is refused before it reaches this datasource.

#### The limits of a search, checked before the request leaves

Intercom nests a search **two levels** deep and takes **fifteen conditions per group**. Past either it answers a `400` whose body names neither the limit nor the part of the filter that reached it, so both are checked here and refused with a message naming what to simplify.

Fifteen is reached without trying: a scope, a segment and a user's own filter add up, and a condition naming several values arrives expanded into **one condition per value**. Branches carrying a single condition are unwrapped and spend no level.

### Sorting

**One endpoint sorts, and it is `/contacts/search`.**

| Collection | Sortable on |
| - | - |
| `IntercomContact` | `name`, `email`, `created_at`, `updated_at`, `signed_up_at`, `last_seen_at`, `last_contacted_at`, `last_replied_at` |
| `IntercomAdmin`, `IntercomTeam`, `IntercomTeamMembership`, `IntercomTicketType`, `IntercomTicketState` | every scalar column, sorted in memory over the whole dataset — exactly. An order on a relation or a Json column is reported in the log and dropped |
| `IntercomConversation`, `IntercomTicket`, `IntercomCompany` | nothing |

`POST /companies/list` has no order parameter at all, and the other two search endpoints **accept a `sort` and ignore it**. Since that is undetectable at runtime, no column of those three is declared sortable and a requested order is reported in the log rather than silently swallowed.

The contact set is deliberately narrower than Intercom's documentation implies: a sort the endpoint refuses is a list view that fails rather than one that comes back unordered. A sort on any other column, or on two columns at once, is reported in the log and the rows come back in the API's order. Two routes of contacts cannot carry a sort either, each logging it: a read by id, which returns the records in the order the ids were named, and `company_id equals X`, whose endpoint takes no order.

### Pagination

**Intercom hands out the page after a cursor and documents that jumping to page N is unsupported**, so reaching page 20 of a conversation, ticket or contact list costs 20 sequential requests. The walk is capped at **50** pages and **7 500** records, with a page size of at most **150**.

Every route out of the walk that is short of what was asked for is **logged**, naming the window it stopped in: the two caps, and two defensive stops — a page that advertises a next cursor and holds nothing, and a cursor already followed.

Two more things follow from cursor pagination:

* **Tickets are bounded far lower: 25 per page.** The search response carries the whole timeline of every ticket and Intercom offers no field selection.
* **Duplicates on a moving dataset.** Intercom documents that records modified between two paginated requests can be served twice; the walk deduplicates by id. A record missed for the same reason cannot be recovered.

`POST /companies/list` takes a page number instead, so companies escape the cursor walk — not its caps: a read naming no window stops after **10** pages, and a read naming one after **7 500** records, each logged.

### Aggregations

**Counting is exact on every collection, and usually one request.** Intercom's `total_count` counts what the query names rather than what a page held. A count over a list of ids is the exception: Intercom has no endpoint that counts a set of ids, so it costs one request per id (one per 15 on contacts) and is refused past 25 ids (300 on contacts), like the read itself.

**Anything beyond a count is refused on the conversation, ticket, contact and company collections.** There is no aggregate endpoint, and grouping over the pages a walk collected would look exact while answering a fraction. Every column is registered non-groupable so the UI never offers a group-by, and a chart built through the API anyway is refused with a message saying why.

**The collections read whole are groupable, and exactly so**: their endpoint hands back every record Intercom holds. A group through a relation — teammates grouped by `team:name`, for instance — resolves the relation first, the way a list view does.

## Conversations

The row carries what a queue is read for: state, priority, assignee and team ids, the company, the tags, and the lifecycle Intercom keeps in `statistics` — `closed_at`, `closed_by_id`, `first_contact_reply_at`, `last_contact_reply_at`, `last_admin_reply_at`, `reopen_count`.

The contact's name is denormalized onto the row by **one bulk read per page**, not one per row, and only when the projection names it. A failure there costs that column, not the page. The e-mail address is a hop away, on the `contact` relation.

See [Conversation and ticket threads](#conversation-and-ticket-threads) for the `timeline` column and what it costs on a list view.

## Tickets

A ticket carries **no `statistics` block**, so neither a closure date nor a last responder exists as a field. Both are derived from the parts, which ride along in the search response whether or not anything asks for them, and therefore cost nothing:

| Column | Derived from |
| - | - |
| `closed_at`, `closed_by_name` | the last transition into a state of category `resolved` |
| `last_reply_at`, `last_responder_name`, `last_responder_type` | the last `comment` part |

Four things to know about them:

* a ticket is not "closed" on Intercom, it enters a **resolved** state;
* the state-change event is matched on its **prefix**, not on the one variant `ticket_state_updated_by_admin`: a workspace running workflows closes tickets through other variants;
* a transition whose target equals the previous state is ignored;
* **a resolved ticket showing no closure date may have been closed all the same**: past the 500-part ceiling the transition falls out of the window. When the projection asks for `closed_at` or `closed_by_name`, that case is detected and logged, since a Date column cannot say "unknown".

Both are **display-only**: `/tickets/search` filters on neither and ignores a sort.

There is **no `GET /tickets` at all** — even an unfiltered ticket list goes through `POST /tickets/search` with a predicate matching everything.

## Contacts

The people who write in, users and leads alike. Cursor-paginated like conversations and tickets, with two routes of its own and one thing no other paginated collection has — [Intercom sorts this one](#sorting).

**A set of ids is read in bulk** — `id IN [...]`, which this endpoint answers and no other does — fifteen at a time, the most one `IN` takes, and up to **300** ids per filter. Past that the read and its count are refused. It is what makes a related list of contacts affordable.

**`company_id equals X` reads `GET /companies/{id}/contacts`.** The search filters no company field, so without that route the contacts of an account would be a refusal rather than a list. It is a bare equality only: an `and` also carrying a permission scope names a narrower set than the account does, and answering it with the account alone would serve contacts the scope excludes.

> [!WARNING]
>
> That route narrows nothing: the account endpoint returns its contacts whole, and the search filters no company field. So **a permission scope or a segment defined on `IntercomContact` turns the related list of an account into a refusal**, naming the condition it could not carry alongside the account.

The two routes do not agree on one thing, and it is visible: the `company_id` column names **the first** of the accounts a contact belongs to, and the `company` relation resolves that same first one — while `company_id equals X` returns **every** contact of X. So an account's related list can show a contact whose `company` points somewhere else.

**A merged contact reads as gone, not as an error.** Intercom drops it from the listing and from the search, and the record lives on under the id it was merged into. A row pointing at the old id comes back empty rather than failing the page.

## Companies

The accounts contacts belong to, and the collection that behaves least like the others.

**Paginated by offset**, with its own caps (see [Pagination](#pagination)). **Looked up, not searched**: `name` and `company_id` — the identifier the customer's own system gave the account, not Intercom's — are the two filters it publishes, each an exact equality, and anything else is refused by name. A record is read through `GET /companies/{id}`, and a set of ids one request each, up to **25**: past that the read is refused rather than truncated.

`GET /companies/scroll` exists and is **deliberately rejected**: one open scroll per application, expiring after a minute, cannot serve two users looking at a list at the same time.

A contact carries its accounts as a list of ids and nothing else, so **projecting `company:name` on a contact list costs one request per distinct account on the page** — which is why that projection is [the one relation of the datasource with a ceiling](#what-a-relation-costs). Reading the account from the contact's record page, or listing contacts from the account, both cost one request.

## What a page costs, and what is held

A list view is a handful of requests rather than one: Intercom joins nothing, so every relation on the page is a read of its own. What keeps that from being paid twice:

* **The workspace's reference lists are held for `reference_cache_ttl`** (**60s**) — teammates, teams, ticket types and ticket states, the tier read whole. Five of a ticket's six relations resolve through them — `contact` is the sixth — and they change a few times a year. What the window trades is bounded staleness: a teammate added mid-session appears within the minute. Set `reference_cache_ttl: 0` to re-read them on every request, or call `datasource.configuration.reference_cache.clear` from a customization that has just changed them.
* **Records are never held.** Serving a page of the customer's data from a store would show a user a row they have just edited in its previous state. The one deduplication that applies to records lasts for a single page being built: a ticket list asks for the same contacts twice — once for the `contact_name` column, once for the `contact` relation — and the second read is answered by the first.
* **Connections are reused.** The client builds on Faraday's persistent adapter, so the requests of a page share one TLS handshake. An adapter the process cannot load, the persistent one or the one named in `adapter`, is refused at boot.

On a page of 15 tickets projecting five relations, that takes the cost from 7 requests to 5 for the first page after boot, and to 2 for every page after it within the window.

> [!WARNING]
>
> **Size the connection pool against your web server's thread count.** The pool holds 25 connections. A thread that finds it full waits half a second and then fails with what reads as a Faraday timeout — Intercom looking slow rather than the pool being narrow — and a timeout on the searches a list view is built on, which travel over `POST`, is not retried. If your server runs more threads than that, raise it:
>
> ```ruby theme={null}
> ForestAdminDatasourceIntercom::Datasource.new(
>   access_token: ENV['INTERCOM_ACCESS_TOKEN'],
>   adapter: [:net_http_persistent, { pool_size: 50 }]
> )
> ```

What none of this touches is the search response itself: Intercom offers no field selection, so a page of tickets carries every ticket's whole timeline whatever the projection says — which is why [tickets are bounded to 25 per page](#pagination).

### Reading records by id in parallel

Intercom has no bulk read for a company, a conversation or a ticket, so some reads cost one request per record: a related list or a page resolving a `company` relation (up to 100 companies), a record read by id, and the timelines of a conversation list (up to 10 conversations). By default those requests go **one after another** — at about 150 ms each, a page resolving 100 companies waits around 15 seconds.

`concurrent_reads` sends that many of them at once. At 4, the same page waits around 4 seconds. The order of the rows, the caps and the refusals are unchanged, and so is the rate limiting: every request still goes through the limiter.

It is `1` by default because **every read in flight holds a connection of the pool** for as long as it waits. A request using four of them leaves four fewer for the other threads, and a thread that finds the pool full fails as a timeout (see above). Size the pool against both figures together — at least the server's thread count times `concurrent_reads`:

```ruby theme={null}
# Puma with 5 threads, 4 reads at once: 20 connections at the busiest.
ForestAdminDatasourceIntercom::Datasource.new(
  access_token: ENV['INTERCOM_ACCESS_TOKEN'],
  concurrent_reads: 4,
  adapter: [:net_http_persistent, { pool_size: 25 }]
)
```

`concurrent_reads: 1` is also the way back to the sequential behaviour if a wider setting misbehaves.

## Rate limits and retries

Intercom meters two budgets: **10 000 requests a minute per app**, allocated in **10-second windows** of 1 667 (the measured `x-ratelimit-limit`), and **25 000 a minute for the whole workspace**, shared across every app the customer runs. A burst therefore takes a `429` while the minute's budget is barely touched, which is why what matters is the instantaneous rate.

The limiter is driven by the headers Intercom returns on every response rather than by a table: it waits out the reset when the window is spent, and counts its own in-flight requests down so several of them do not go out on the same stale figure. It waits **12 seconds** at most: a reset further out than that sends the request anyway, logs once per window, and leaves it to the retry.

It sits **in front of** the 429 retry, not instead of it: the retry remains the defence against the part of the workspace budget spent by traffic this process cannot see.

**The retry is bounded, deliberately.** One attempt waits **12 seconds** at most (`max_interval:`); past it the `429` surfaces as an error instead, so a saturated endpoint answers the user with a message rather than with a page that arrives long after they gave up.

Raise it — or lower `max_retries` — to trade the other way:

```ruby theme={null}
ForestAdminDatasourceIntercom::Datasource.new(
  access_token: ENV['INTERCOM_ACCESS_TOKEN'],
  retry_policy: ForestAdminDatasourceIntercom::RetryPolicy.new(max_retries: 1, max_interval: 65)
)
```

To meter on your own side instead, take the limiter out of the stack:

```ruby theme={null}
ForestAdminDatasourceIntercom::Datasource.new(
  access_token: ENV['INTERCOM_ACCESS_TOKEN'],
  rate_limiter: nil
)
```

Retries apply to `429`, `500`, `502`, `503` and `504`, plus timeouts and dropped connections:

* a `429` is retried on any verb, Intercom having rejected the request before processing it;
* `GET`, `HEAD` and `OPTIONS` are retried on everything above;
* the read-only `POST`s — the three searches and `/companies/list` — are retried on a dropped connection only. A `5xx` or a timeout on them is not: a `502` answering `POST /conversations/search` surfaces as an error.

## Privacy

The body of a conversation is raw personal data, and this datasource is built on that assumption.

* **Nothing logs a body.** Logs carry the operation and the counts. An `APIError` message never quotes the response, but `APIError#body` keeps it — on a `200` that fails to parse, that is the raw payload — so do not log it.
* **`display_as=plaintext` on every conversation and ticket read.** The bodies are HTML written by end customers; rendering third-party HTML inside Forest is neither safe nor useful.
* **The regional host is configurable** so a workspace's data stays in its region.
* **The team's internal notes are in the thread**, next to what the customer was told, and **a ticket list carries every ticket's message bodies** whether or not anything asks for them: Intercom offers no field selection. Where a role should not read them, restrict its access to `IntercomConversation` and `IntercomTicket` in [roles and permissions](https://docs.forest.app/get-started/control/roles-permissions.md), or remove the column from your Forest back-end with `collection.remove_field('timeline')`.

## Boot-time introspection

Constructing the datasource reads **four** endpoints — one request each, unless Intercom paginates one of the introspections, in which case it is followed up to **10** pages.

Three are of one kind: `GET /ticket_types` for the attribute columns of `IntercomTicket`, and `GET /data_attributes?model=contact` and `?model=company` for those of `IntercomContact` and `IntercomCompany`.

The fourth is `GET /me`, and it reads no column: Intercom echoes in a response header the API version it served, and it serves the workspace's own default when the pin is not honoured — whose payloads are shaped differently. That echo is the only place the substitution shows, so it is checked at boot and reported as a warning.

> [!NOTE]
>
> All four run on the boot connection — short timeouts, one quick retry — so a slow Intercom cannot hold a Rails boot for minutes.
>
> A token missing a permission costs the columns it could not read, or the version check, never the boot: a `403` degrades to a warning, and so does any failure of `/me`. **Any other failure of an attribute read — a timeout, a `5xx`, a `429` past the retry, a revoked token — fails the boot**: degraded, that process would publish a schema missing columns the others have, and keep it until it restarts.

## What is not here yet

This version is read-only. Writes and further capabilities may come in later versions.

## Errors

Every class below lives under `ForestAdminDatasourceIntercom::`. The table drops the prefix for width, but a `rescue` needs it in full — `rescue ForestAdminDatasourceIntercom::APIError`.

| Class | Surfaces as | Raised by |
| - | - | - |
| `ConfigurationError` | boot failure | A missing or blank `access_token`, a blank `api_version`, an unknown `region`, a `base_url` that is not an absolute http(s) URL or carries credentials, a `reference_cache_ttl` that is not a finite number of seconds, an `adapter` of the wrong shape or that cannot be loaded, a `concurrent_reads` that is not a whole number of one or more, or a malformed search-field table. |
| `UnsupportedOperatorError` | `400` | Anything Intercom cannot answer exactly: an operator its DSL refuses on that field, a date bound inside a day, a tree deeper than two levels, a group past fifteen conditions, a relation fan-out past the cap, a group-by on a paginated collection, a read or a count over more ids than one read fetches. |
| `APIError` | `500` | Any other failed Intercom call. Carries the HTTP `status` and the response `body` — parsed JSON, or the raw text when Intercom did not answer JSON. |

An `UnsupportedOperatorError` reaches the user with its message intact: each one names something the user set and can change, and the message is the only place they learn which condition to fix.

## Logging

The datasource uses `Rails.logger` when available, and falls back to `Logger.new($stderr)`. You can override it explicitly:

```ruby theme={null}
ForestAdminDatasourceIntercom.logger = MyLogger.new
```

Best-effort paths log a warning and degrade rather than failing the whole page render. Among others: a boot read answered `403`, a denormalized name column the token could not read, a conversation thread past the embed cap, a truncated cursor walk or companies read, an order no endpoint honours, a date filter widened to the UTC day, a ticket timeline truncated at 500 parts, a resolved ticket whose closure fell past that window, a ticket attribute declared with two data types, an API version Intercom did not honour, and a saturated rate-limit window.

## Source code

This connector is open source. Browse the code or contribute on GitHub:

[`forest_admin_datasource_intercom`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_intercom)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.