# Connect your users

> Send your user to Apigami's connect page, exchange the code it returns, and save the user reference for your API calls.

Your user connects their Apigami account to your app on Apigami's connect page. They sign in
with their email, attach the browser they're using, and come back to your app with a one-time
`code`. Your server exchanges that code for a **user reference**, `user_ref`, and sends it in
every later API call.

The flow is OAuth 2.1 with PKCE: an OAuth library that supports PKCE and pushed authorization
requests works, pointed at the addresses below. Connecting can approve your app's sites in the
same click, when Apigami's own window attaches the browser ([step 2](#2-the-user-connects)). Any
other site is approved when your app [first asks for access](/docs/consent/).

## Before you start

In the [dashboard](https://app.apigami.com), open your app:

1. Set its visibility to **Unlisted**. A private app only accepts members of your team.
2. Register your redirect URL, such as `https://example.com/apigami/callback`.
3. Create an [API key](/docs/authentication/) and copy your app ID.

A redirect URL must match a registered one exactly, scheme, host, port and path included. Only the
query string and the fragment can vary, so a hash-routed app registers its page once. `https` is
required, except on `localhost`.

## 1. Start the connection on your server

Do this when the user clicks your **Connect** button, not when the page loads: the link expires
after 90 seconds.

Generate a PKCE verifier and a `state`, and keep both in the user's server-side session. Then push
the request with your API key:

```js title="Start the connection"
import { createHash, randomBytes } from 'node:crypto'

const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
req.session.apigami = { codeVerifier, state }

const pushed = await fetch('https://api.eu.apigami.com/connect/par', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.APIGAMI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    redirect_uri: 'https://example.com/apigami/callback',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
    login_hint: 'jane@example.com', // optional: prefills the user's email
  }),
})
if (!pushed.ok) throw new Error((await pushed.json()).error)
const { request_uri } = await pushed.json()

const connectUrl = new URL('https://app.apigami.com/connect')
connectUrl.search = new URLSearchParams({
  client_id: process.env.APIGAMI_APP_ID,
  request_uri,
}).toString()
res.redirect(connectUrl.toString())
```

`POST /connect/par` answers `201`:

```json
{ "request_uri": "urn:ietf:params:oauth:request_uri:0b5c…", "expires_in": 90 }
```

<details>
<summary>Without the pushed request</summary>

You can also put the parameters in the link itself, without calling `POST /connect/par`:

```text
https://app.apigami.com/connect?client_id=YOUR_APP_ID
  &redirect_uri=https%3A%2F%2Fexample.com%2Fapigami%2Fcallback
  &state=YOUR_STATE
  &code_challenge=YOUR_CODE_CHALLENGE
  &code_challenge_method=S256
```

`login_hint` works here too, but an email in a URL ends up in logs and browser history. Use the
pushed request whenever you pass an email.

</details>

## 2. The user connects

The connect page asks for the user's email. A new address gets an account at once; an existing
one receives a 6-digit code by email. The page then installs Apigami if needed, has the user
attach this browser to their account, and sends them back to you.

In a browser that isn't attached to the user's account yet, running Apigami 0.15.0 or later,
Apigami opens its own window there, showing the user's email, your app and the sites it asks for:
the domains your manifest declares with `"via": "extension"`, and those of the applications it
uses. One **Allow**, followed by Chrome's permission prompt for those sites, attaches the browser,
connects the user to your app and approves those sites. Refusing the window sends the user back
with `error=access_denied`; closing it attaches nothing, and the page offers to reopen it. A
browser already attached to the account gets no window and approves nothing here; an older
version of Apigami is attached by the connect page itself, without a window, and approves nothing
either. Either way, your app asks for its sites [on its first call](/docs/consent/).

The user has 30 minutes to finish. On success, the redirect looks like this:

```text title="Successful connection"
https://example.com/apigami/callback?code=q4Hc…&state=YOUR_STATE
```

Check that `state` matches the one in the user's session, then remove it so it can't be reused.
The `code` is valid for 60 seconds and works once.

Two errors come back to the same address, with the same `state`:

| Redirect | Meaning |
|---|---|
| `?error=access_denied&state=…` | The user cancelled or refused. Don't retry on your own; offer the button again. |
| `?error=expired&state=…` | The 30 minutes ran out. Start a new connection from step 1. |

An unknown app or an unregistered redirect URL shows an explanation on the connect page and
never redirects.

## 3. Exchange the code

From your callback, on your server:

```js title="Exchange the code"
app.get('/apigami/callback', async (req, res) => {
  const { code, state, error } = req.query
  const pending = req.session.apigami
  delete req.session.apigami
  if (!pending || state !== pending.state) return res.status(400).send('Unknown connection')
  if (error === 'access_denied') return res.redirect('/settings?apigami=cancelled')
  if (error === 'expired') return res.redirect('/settings?apigami=expired')

  const exchanged = await fetch('https://api.eu.apigami.com/connect/token', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.APIGAMI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      code_verifier: pending.codeVerifier,
      redirect_uri: 'https://example.com/apigami/callback',
    }),
  })
  if (!exchanged.ok) return res.redirect('/settings?apigami=failed')

  const { user_ref, install_ref } = await exchanged.json()
  await db.users.update(req.user.id, { apigamiUserRef: user_ref, apigamiInstallRef: install_ref })
  res.redirect('/settings?apigami=connected')
})
```

Send the same `redirect_uri` as in step 1, character for character. The answer:

```json title="POST /connect/token"
{
  "user_ref": "usr_3f9c1a0e7b2d4c58a61e9f0b2d7c4e13",
  "install_ref": "ins_8b1e4f2a9c3d4e5f8a7b6c5d4e3f2a1b",
  "installs": [
    { "install_ref": "ins_8b1e4f2a9c3d4e5f8a7b6c5d4e3f2a1b", "label": null, "online": true }
  ]
}
```

| Field | Meaning |
|---|---|
| `user_ref` | The user, for every app of your team. Send it as `user_ref` in [`POST /call`](/docs/operations/). |
| `install_ref` | The browser the user connected from, or `null` if none was attached during the flow. |
| `installs` | Every browser attached to the user that your app can reach, with the name the user gave it. |

A failed exchange answers `400 invalid_grant`: the code is wrong, expired, already used, issued
to another app, or the verifier or redirect URL doesn't match. The code can't be retried; start a
new connection.

## Save the user reference

Save `user_ref` against your user. It stays the same when they reinstall Apigami or add a browser,
and it's the same for every app your team owns. Another team sees a different reference for the
same person, and never their Apigami email unless you passed it yourself.

Most apps send only `user_ref`: Apigami picks the user's most recently active browser that can
reach the site. Save `install_ref` too if your app works with one browser in particular, for
example the one signed in to a given account, and send it as `install_ref`. A named browser is
never replaced by another: if it's offline, the call fails with `503 extension offline`.

## A user's browsers

```bash
curl https://api.eu.apigami.com/v1/users/$APIGAMI_USER_REF/installs \
  -H "Authorization: Bearer $APIGAMI_API_KEY"
```

```json
{
  "installs": [
    {
      "user_ref": "usr_3f9c1a0e7b2d4c58a61e9f0b2d7c4e13",
      "install_ref": "ins_8b1e4f2a9c3d4e5f8a7b6c5d4e3f2a1b",
      "label": "Work laptop",
      "flavor": "standard",
      "online": true,
      "presence": "online",
      "presence_changed_at": "2026-09-27T08:02:11.000Z",
      "last_seen_at": "2026-09-27T09:41:05.000Z",
      "granted_domains": ["example.com"],
      "connected_at": "2026-09-01T08:00:00.000Z"
    }
  ],
  "next": null
}
```

`online` is the live connection; `presence` is the status [events](/docs/webhooks/) announce.
`granted_domains` lists, among the sites that browser lets Apigami access, those the user approved
for your app; `null` means an older install that doesn't report them. A `user_ref` of another team, or of a user not connected to
this app, answers `404 unknown user_ref`; a user Apigami disabled answers `403 account disabled`, and one who left
your private app's team `403 app is private`. A call to an offline browser returns
`503 extension offline`; it isn't queued. The former address, `/v1/users/{user_ref}/extensions`,
answers the same and goes away.

## Everyone connected to your app

`GET /v1/users` lists the people connected to your app, with or without a browser (`user_ref`,
`connected_at`); `GET /v1/installs` lists the browsers of those people, in the shape above. Both come
in pages of people, oldest connection first: `limit` (500 by default, 1,000 at most) and the `next`
of the previous answer as `cursor`, until `next` is `null`. One person's browsers are never split
between two pages. A disabled account, or someone who left your private app's team, isn't listed.

```bash
curl "https://api.eu.apigami.com/v1/installs?limit=500" \
  -H "Authorization: Bearer $APIGAMI_API_KEY"
```

## The install link

For your own browser or a team tool, use the app's install link from the dashboard:

```text
https://app.apigami.com/connect?app_id=YOUR_APP_ID
```

The install link and the connect link are the same link. Once the user is connected, it opens your
app's home page full page ([The home page](/docs/app-pages/#the-home-page)), which shows the
settings of the apps it uses, else its settings page
([The settings page](/docs/app-pages/#the-settings-page)), and opens it again each time a
connected user comes back to it. It ends on **Connected**, without a redirect, only when your app has
neither. Copy the user reference from the app's **Browsers** page, under **Connected users**, or receive it in an [`app_user.created` event](/docs/webhooks/).
Users of a private app sign in as team members.

<details>
<summary>Limits and attested emails</summary>

An unverified app connects up to ten users; the eleventh sees `client not verified` on the connect
page. See [verification](/docs/authentication/#verification).

`login_hint` only prefills the email: the user still proves it with a code. An app that Apigami
has reviewed can be allowed to vouch for the emails it sends through `POST /connect/par`; its users
then skip the code. [Contact us](mailto:contact@apigami.com) if your app already verifies its users'
emails. With an attested email, Apigami's window opens even in a browser already attached to that
account: whoever is signed in to the connect page may not be the person behind that email.

| `POST /connect/par` error | Fix |
|---|---|
| `400 redirect_uri is not registered for this app` | Register the URL in the dashboard, path included. |
| `400 code_challenge (S256) is required` | Send a 43-character base64url SHA-256 challenge and `code_challenge_method: "S256"`. |
| `400 login_hint must be an email address` | Remove it or send a valid address. |
| `400 state must be a string of at most 2048 characters` | Shorten `state`. |

</details>

Next, [make a request](/docs/quickstart/#3-list-your-tabs). The first call to a new site asks the user for approval.