# Intercom widgets

Show an Intercom contact with its open conversation and ticket counts, using a typed, read-only plugin on your own infrastructure.

Source: https://docs.getaviato.com/integrations/intercom

`IntercomPlugin` implements the same `IntegrationPlugin` contract as `StripePlugin` and
`ZendeskPlugin`. It adds a contact card to a record's standard or custom overview: name,
email, role, location, sign-up and last-seen dates, the number of open conversations and
the number of open tickets, with a link to the contact's Intercom inbox. Requests travel
from the browser to your connector and then directly to Intercom. Provider credentials,
IDs and raw responses are not sent to Aviato's control plane.

These APIs require the TypeScript SDK **0.6.0**, the NestJS adapter **0.4.0**, and the
Go and Laravel SDKs **0.7.0**, together with connector **0.4.0** or later. This site tracks source code, not registry publication.
Upgrade the customer connector **before** updating SDKs or relying on the SDK declaration:
the widget manifest carries the Intercom region, and connectors without Intercom support
do not show the widget (unknown providers are hidden). Installing an SDK does not install
or upgrade the connector.

## Configure a read-only access token [#configure-a-read-only-access-token]

In Intercom's Developer Hub, create an app for your workspace and grant it only:

* **Read and list users and companies**, required for the default `externalId` binding,
  which searches contacts. **Read one user and one company** is enough only for the
  `contactId` binding, which fetches a single contact.
* **Read conversations**, required for the open conversation count.
* **Read tickets**, optional. Without it the open tickets count shows "—".

Do not grant any write permission. Copy the app's access token and store it in
`INTERCOM_WIDGET_TOKEN` in the **customer connector's process environment**, using your
deployment's secret manager. The SDK receives only the variable's name. Restart the
connector when its environment changes. The integration does not perform an OAuth install
or refresh flow.

Intercom offers no API to verify a token's permissions. Unlike the Zendesk widget, the
connector cannot confirm that the token is read-only: `readOnly: true` is required in
configuration, but the read-only guarantee rests on the app setup above. The connector
only ever sends the requests listed under [Requests per view](#requests-per-view).

Set `region` to the data region of your Intercom workspace: `us`, `eu` or `au`. It selects
a fixed Intercom API host (`api.intercom.io`, `api.eu.intercom.io` or `api.au.intercom.io`).
URLs and custom hosts are rejected, and the browser cannot change the region. The connector
refuses a token whose workspace reports a different known region; if Intercom reports a
region the connector does not recognize, it trusts the configured one.

## Bind a record to a contact [#bind-a-record-to-a-contact]

A widget is bound to one column of the record, in one of two ways:

| Option       | Column holds            | Matched against                      |
| ------------ | ----------------------- | ------------------------------------ |
| `externalId` | Your own user ID        | The contact's Intercom `external_id` |
| `contactId`  | The Intercom contact ID | The contact's Intercom ID            |

Set exactly one. Using `externalId` with the primary key (`{ field: "id" }`) works when
you pass that ID to Intercom as the user's `external_id`. If more than one contact has the
same `external_id`, the widget shows an unavailable state instead of choosing one.
Who can see the widget depends on the bound column; see [Visibility](#data-and-permissions).

To bind through a related record (for example an order's customer), add
`relation: "customer"`. The relation must be a `belongsTo` relation declared or introspected
by the connector, with one hop at most. An optional `title` overrides the widget heading.

## TypeScript [#typescript]

After [mounting your application plugin](/integrations/typescript):

```ts
import { IntercomPlugin } from "@getaviato/sdk";

plugin.use(new IntercomPlugin({
    region: "eu",
    credentialEnv: "INTERCOM_WIDGET_TOKEN",
    readOnly: true,
}).contact({
    collection: "customers",
    name: "intercom-contact",
    externalId: { field: "id" },
}));
```

Use `contactId: { field: "intercom_contact_id" }` instead of `externalId` to bind the
Intercom contact ID.

## NestJS [#nestjs]

Register the integration as a singleton feature provider. Configure one root with
`AviatoModule.forRoot` / `forRootAsync`, and bootstrap with `rawBody: true` as described
in the [NestJS guide](/integrations/nestjs).

```ts
import { AviatoModule, IntercomPlugin } from "@getaviato/nestjs";

const contactFeature = AviatoModule.forFeature({
    providers: [{
        provide: IntercomPlugin,
        useFactory: () => new IntercomPlugin({
            region: "eu",
            credentialEnv: "INTERCOM_WIDGET_TOKEN",
            readOnly: true,
        }).contact({
            collection: "customers", name: "intercom-contact",
            externalId: { field: "id" },
        }),
    }],
    plugins: [IntercomPlugin],
});
```

Import `contactFeature` into a Nest module. The access token itself belongs on the
customer connector.

## Go [#go]

With `aviato "github.com/getaviato/aviato-go"` imported and your plugin configured:

```go
intercom, err := aviato.NewIntercomPlugin(aviato.IntercomPluginOptions{
    Region: "eu", CredentialEnv: "INTERCOM_WIDGET_TOKEN", ReadOnly: true,
})
if err != nil {
    return err
}
if _, err := intercom.Contact(aviato.IntercomContactWidget{
    Collection: "customers", Name: "intercom-contact",
    ExternalID: aviato.RecordBinding{Field: "id"},
}); err != nil {
    return err
}
if err := plugin.Use(intercom); err != nil {
    return err
}
```

Set `ContactID` instead of `ExternalID` to bind the Intercom contact ID. `Contact` returns
an error unless exactly one of the two is set.

## Laravel [#laravel]

Register in your service provider's `boot` method:

```php
use Aviato\Laravel\Facades\Aviato;
use Aviato\Laravel\Integrations\IntercomPlugin;
use Aviato\Laravel\Integrations\RecordBinding;

Aviato::use((new IntercomPlugin(
    region: 'eu',
    credentialEnv: 'INTERCOM_WIDGET_TOKEN',
    readOnly: true,
))->contact(
    collection: 'customers',
    name: 'intercom-contact',
    externalId: new RecordBinding(field: 'id'),
));
```

Pass `contactId:` instead of `externalId:` to bind the Intercom contact ID. Keep the token
in the connector environment even when your application runs in Laravel.

## Data and permissions [#data-and-permissions]

The card shows the contact's name, email, role, location, sign-up date, last-seen date,
open conversations and open tickets. A null or empty binding shows an unlinked state. The
browser never provides an Intercom ID directly. The inbox link is built from the region
and the identifiers returned by the connector, never from a URL supplied by Intercom.

Aviato checks local record and binding-column access before making provider requests.
Related bindings also require access to the source foreign-key columns, target record and
target binding column.

**Visibility.** With the default `externalId: { field: "id" }` binding, anyone who can read
the record sees the contact card, because the primary key cannot be restricted. If you need
tighter control, bind to a dedicated column, for example `intercom_contact_id` or a column
holding the external ID, and restrict that column with roles.

Conversations and ticket contents, notes, tags, attributes and companies are not included.
There are no reply, close, assign or tag actions.

## Requests per view [#requests-per-view]

Each view makes these requests to Intercom, using API version 2.16. The first two run
together; the two counts run together once the contact is known:

1. `GET /me`, to confirm the token and read the workspace.
2. `GET /contacts/{id}` for a `contactId` binding, or `POST /contacts/search` on
   `external_id` for an `externalId` binding. The search matches users only (role `user`),
   not leads.
3. `POST /conversations/search`, counting conversations with `open = true`.
4. `POST /tickets/search`, counting tickets with `open = true`.

The `POST` requests are searches and change nothing. Counts use Intercom's `open` flag.
Snoozed conversations and tickets are expected to count as open; this is checked against a
real workspace before release. If a count request fails, that cell shows "—" and the rest
of the card still renders. If `/me` or the contact lookup fails, the widget is unavailable.

A contact that was merged into another contact or deleted shows the empty state. If the
workspace's app ID is unexpected, the card still renders without the "Open in Intercom"
link. Intercom's search can take a short while to index newly created contacts, so a
brand-new user may briefly show the empty state.

## Limits and troubleshooting [#limits-and-troubleshooting]

All requests share a ten-second deadline. Each response is limited to one MiB, redirects
are refused, and nothing is retried. Widget responses use `Cache-Control: no-store`.

For an unavailable widget, check the region, the connector environment, the app's
permissions, the record binding, and Intercom rate limits. A "—" in one count usually means
the matching permission (Read conversations or Read tickets) is missing. No raw provider
error is exposed. The normal audit trail records the local record read and its outcome,
without provider IDs, credentials or response bodies.