Connect your users
MarkdownYour 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). Any other site is approved when your app first asks for access.
Before you start
Section titled “Before you start”In the dashboard, open your app:
- Set its visibility to Unlisted. A private app only accepts members of your team.
- Register your redirect URL, such as
https://example.com/apigami/callback. - Create an API key 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
Section titled “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:
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:
{ "request_uri": "urn:ietf:params:oauth:request_uri:0b5c…", "expires_in": 90 }Without the pushed request
You can also put the parameters in the link itself, without calling POST /connect/par:
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=S256login_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.
2. The user connects
Section titled “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.
The user has 30 minutes to finish. On success, the redirect looks like this:
https://example.com/apigami/callback?code=q4Hc…&state=YOUR_STATECheck 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
Section titled “3. Exchange the code”From your callback, on your server:
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:
{ "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. |
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
Section titled “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
Section titled “A user’s browsers”curl https://api.eu.apigami.com/v1/users/$APIGAMI_USER_REF/installs \ -H "Authorization: Bearer $APIGAMI_API_KEY"{ "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 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
Section titled “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.
curl "https://api.eu.apigami.com/v1/installs?limit=500" \ -H "Authorization: Bearer $APIGAMI_API_KEY"The install link
Section titled “The install link”For your own browser or a team tool, use the app’s install link from the dashboard:
https://app.apigami.com/connect?app_id=YOUR_APP_IDThe 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), which shows the
settings of the apps it uses, else its settings page
(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.
Users of a private app sign in as team members.
Limits and attested emails
An unverified app connects up to ten users; the eleventh sees client not verified on the connect
page. See 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 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. |
Next, make a request. The first call to a new site asks the user for approval.