Skip to content

Connect your users

Markdown

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). Any other site is approved when your app first asks for access.

In the dashboard, 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 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.

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:

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:

{ "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=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.

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:

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.

From your callback, on your server:

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:

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.
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 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.

Terminal window
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.

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.

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

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_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), 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.