Skip to content

Guides

Call APIs on Behalf of Users

OAuth, Connect, and identity-aware token retrieval, end to end.

By the end of this guide, an application backend calls third-party APIs on behalf of any logged-in user, without storing tokens and without tracking grant_id in the application database.

The flow:

  1. The end user clicks Connect Google in the frontend.
  2. Alter Connect runs the OAuth flow and writes a grant bound to that user’s identity.
  3. The backend calls app.request() with the user’s JWT in scope. The SDK resolves the grant from the JWT, refreshes the token, injects it, and returns the response.
  • An app with a runtime API key (alter_rk_…) created from the portal’s Backend service template, which carries every scope this guide uses (connect:initiate and grants:write for Connect sessions and listing connections, tokens:retrieve / proxy:execute and audit:emit for provider calls). An app key in the older alter_key_… format was created before the September 2026 key-security upgrade and no longer authenticates — mint a new one.
  • An identity provider configured for the app (Auth0, Clerk, Okta, WorkOS, or custom OIDC). End users must already be authenticated through the IDP when they reach the backend.
  • A provider configured — this guide uses Google as the example.
  • The Alter SDK installed and the Connect SDK installed in the frontend.

When the user clicks Connect Google in the frontend, the frontend asks the backend for a short-lived session token. The backend mints it via the SDK. The examples below construct the SDK client once at module scope and mount the routes on a framework router:

import os
from fastapi import APIRouter, Depends
from alter_sdk import App
app = App(api_key=os.environ["ALTER_API_KEY"]) # SDK client
router = APIRouter()
@router.post("/api/connect-session")
async def create_session(user = Depends(authenticated_user)):
session = await app.create_connect_session(
allowed_providers=["google"],
allowed_origin="https://app.example.com",
user_token=user.jwt, # binds the grant to this user
)
return {"sessionToken": session.session_token}
import express from "express";
import { App } from "@alter-ai/alter-sdk";
const app = new App({ apiKey: process.env.ALTER_API_KEY! }); // SDK client
const router = express.Router();
router.post("/api/connect-session", authenticated, async (req, res) => {
const session = await app.createConnectSession({
allowedProviders: ["google"],
allowedOrigin: "https://app.example.com",
userToken: req.user.jwt,
});
res.json({ sessionToken: session.sessionToken });
});

The session token is short-lived, single-use, and locked to the user identity carried by the JWT.

authenticated_user (a FastAPI dependency) and authenticated (Express middleware) stand in for your app’s existing sign-in layer. They are not part of the Alter SDK: each must reject unauthenticated requests and expose the signed-in user’s IDP-issued JWT as user.jwt / req.user.jwt.

2. Open the Connect widget on the frontend

Section titled “2. Open the Connect widget on the frontend”
import AlterConnect from "@alter-ai/connect";
const alterConnect = AlterConnect.create();
const connectButton = document.querySelector<HTMLButtonElement>("#connect-google");
if (!connectButton) throw new Error("Connect button not found");
connectButton.disabled = true;
const { sessionToken } = await fetch("/api/connect-session", { method: "POST" }).then(r => r.json());
connectButton.disabled = false;
connectButton.addEventListener("click", () => {
void alterConnect.open({
token: sessionToken,
onSuccess: (grants, completion) => {
// No need to store grant_id — the backend resolves retained grants
// from the user's JWT on subsequent calls.
for (const grant of grants) {
console.log("Connected", grant.provider, grant.account_identifier);
}
if (completion.failedGrants.length > 0) {
console.warn("Some providers were not retained", completion.failedGrants);
}
},
});
});

Fetch the session before enabling the button so the popup launch remains directly attached to the click. The desktop flow calls onSuccess(grants, completion) after OAuth: grants contains retained grants and completion.failedGrants reports partial failures. On mobile-classified devices the widget performs full-page navigation; recover completion by polling the session from the application backend because the current flow does not restore the original page callbacks. Each retained grant is stored in Alter and bound to this user.

2b. No frontend widget? Send the hosted Connect page instead

Section titled “2b. No frontend widget? Send the hosted Connect page instead”

The widget is one consumer of the session — not the only one. The same ConnectSession (TS) also carries connect_url, a ready-to-open URL for the fully hosted Connect page. Deliver it through whatever channel reaches the user — an HTTP redirect, a link in an email or chat message, a URL printed by a CLI — and detect completion by polling the session from the backend. This is the natural path for a backend-only service, a CLI-driven flow, or any integration that cannot mount the JS widget:

session = await app.create_connect_session(
allowed_providers=["google"],
user_token=user.jwt,
)
send_link_to_user(session.connect_url) # redirect, email, chat, CLI output…
# Size the poll to the session's own lifetime — the default poll timeout
# (300s) is shorter than the session's expiry.
grants = await app.poll_connect_session(
session.session_token, timeout=session.expires_in
)
const session = await app.createConnectSession({
allowedProviders: ["google"],
userToken: user.jwt,
});
sendLinkToUser(session.connectUrl); // redirect, email, chat, CLI output…
// Size the poll to the session's own lifetime — the default poll timeout
// (300s) is shorter than the session's expiry.
const grants = await app.pollConnectSession(session.sessionToken, {
timeoutMs: session.expiresIn * 1000,
});

Mind the two clocks when the link is delivered out-of-band: the session lives for the returned expires_in seconds (expires_at is the absolute instant) — a link opened later hits an expired session — and the poll’s default timeout is shorter than that lifetime, raising ConnectTimeoutError if the user has not completed yet, so size it to the session as above (the session stays completable and re-pollable until it expires). Everything else in this guide is unchanged: the session carries the same binding, expiry, and provider allowlist whichever way the user reaches it. See create_connect_session() / createConnectSession() for the full return shape and poll_connect_session() / pollConnectSession() for polling semantics.

Polls also spend the API key’s per-minute request budget, and at the default two-second interval that is roughly 30 requests per minute for every session still in flight. The SDKs back off and keep polling when they are throttled, so a busy moment slows a flow rather than failing it — but if many users connect at once, size the key’s per-minute limit to the expected concurrency instead of polling faster.

The backend constructs the SDK once with a user_token_getter that returns the calling user’s JWT. Every request() carries the JWT; the SDK resolves the grant per-call.

import os
from contextvars import ContextVar
from alter_sdk import App, HttpMethod
current_jwt: ContextVar[str] = ContextVar("current_jwt")
app = App(
api_key=os.environ["ALTER_API_KEY"],
user_token_getter=lambda: current_jwt.get(),
)
@router.get("/api/calendar/events")
async def list_events(user = Depends(authenticated_user)):
current_jwt.set(user.jwt)
response = await app.request(
HttpMethod.GET,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
provider="google",
)
return response.json()
import { App, HttpMethod } from "@alter-ai/alter-sdk";
import { AsyncLocalStorage } from "node:async_hooks";
const jwtStore = new AsyncLocalStorage<string>();
const app = new App({
apiKey: process.env.ALTER_API_KEY!,
userTokenGetter: () => jwtStore.getStore()!,
});
router.get("/api/calendar/events", authenticated, async (req, res) => {
await jwtStore.run(req.user.jwt, async () => {
const response = await app.request(
HttpMethod.GET,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
{ provider: "google" },
);
res.json(await response.json());
});
});

Note the call site: no grant_id, only provider. The backend uses the JWT to find the grant.

A user can have more than one Google account connected. When app.request(provider="google") matches multiple grants, the SDK raises AmbiguousGrantError with the account identifiers. Prompt the user to pick:

from alter_sdk import AmbiguousGrantError
try:
response = await app.request(HttpMethod.GET, url, provider="google")
except AmbiguousGrantError as e:
chosen = await prompt_user_to_pick(e.account_identifiers)
response = await app.request(
HttpMethod.GET, url, provider="google", account=chosen,
)
import { AmbiguousGrantError } from "@alter-ai/alter-sdk";
try {
response = await app.request(HttpMethod.GET, url, { provider: "google" });
} catch (e) {
if (e instanceof AmbiguousGrantError) {
const chosen = await promptUserToPick(e.accountIdentifiers);
response = await app.request(
HttpMethod.GET, url, { provider: "google", account: chosen },
);
} else { throw e; }
}

For an in-app “Connected accounts” page, list grants visible to the calling user:

@router.get("/api/connections")
async def list_connections(user = Depends(authenticated_user)):
current_jwt.set(user.jwt)
page = await app.list_grants()
# list_grants() returns a discriminated union — branch on grant_kind before
# reading OAuth-only fields (managed-secret items have no provider_id).
return [
{"provider": g.provider_id, "account": g.account_identifier, "grant_id": g.grant_id}
for g in page.grants
if g.grant_kind == "oauth"
]
router.get("/api/connections", authenticated, async (req, res) => {
await jwtStore.run(req.user.jwt, async () => {
const page = await app.listGrants();
// Branch on grantKind — managed-secret items have no providerId.
res.json(
page.grants
.filter(g => g.grantKind === "oauth")
.map(g => ({ provider: g.providerId, account: g.accountIdentifier, grantId: g.grantId })),
);
});
});

The Wallet dashboard at wallet.alterauth.com gives end users this view for free — building a custom version is optional.

await app.revoke_grant(grant_id)

The user is also free to revoke from the Wallet at any time.

ErrorLikely causeFix
GrantNotFoundErrorThe JWT did not match any grant.The user hasn’t completed Connect yet. Show the Connect widget.
AmbiguousGrantErrorThe user has connected the provider more than once.Pass account=... per the multi-account pattern above.
GrantRevokedError / CredentialRevokedErrorUser revoked in the Wallet or at the provider.Show the Connect widget to re-authorize.
ScopeReauthRequiredErrorThe app added a required scope after the user connected.Show the Connect widget to re-authorize with the new scope set.
PolicyViolationErrorA policy denied the call (for example, time-of-day or IP).Inspect e.policy_error. See Policies.
QuotaExceededErrorA request-quota rule denied the call.Use the error’s retry-after value to wait until the quota window resets.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.