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:
- The end user clicks Connect Google in the frontend.
- Alter Connect runs the OAuth flow and writes a grant bound to that user’s identity.
- 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.
Prerequisites
Section titled “Prerequisites”- An app with a runtime API key (
alter_rk_…, or legacyalter_key_…). - An identity provider configured for the app (Auth0, Clerk, Okta, 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.
Walkthrough
Section titled “Walkthrough”1. Mint a Connect session on the backend
Section titled “1. Mint a Connect session on the backend”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 osfrom fastapi import APIRouter, Dependsfrom alter_sdk import App
app = App(api_key=os.environ["ALTER_API_KEY"]) # SDK clientrouter = 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 clientconst 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.
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 { sessionToken } = await fetch("/api/connect-session").then(r => r.json());
await alterConnect.open({ token: sessionToken, onSuccess: (connection) => { // Connection is created. No need to store grant_id — the backend // resolves it from the user's JWT on every subsequent call. console.log("Connected", connection.provider, connection.account_identifier); },});The widget opens a popup (or, on mobile, redirects), runs OAuth, and calls onSuccess with the connection metadata. The grant is now stored in Alter, bound to this user.
3. Call the provider from the backend
Section titled “3. Call the provider from the backend”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.
from alter_sdk import App, HttpMethodfrom contextvars import ContextVar
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.
Patterns
Section titled “Patterns”Multi-account users
Section titled “Multi-account users”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:
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, )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; }}Listing the user’s connections
Section titled “Listing the user’s connections”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.
Revoking a grant from the backend
Section titled “Revoking a grant from the backend”await app.revoke_grant(grant_id)The user is also free to revoke from the Wallet at any time.
Troubleshooting
Section titled “Troubleshooting”| Error | Likely cause | Fix |
|---|---|---|
GrantNotFoundError | The JWT did not match any grant. | The user hasn’t completed Connect yet. Show the Connect widget. |
AmbiguousGrantError | The user has connected the provider more than once. | Pass account=... per the multi-account pattern above. |
GrantRevokedError / CredentialRevokedError | User revoked in the Wallet or at the provider. | Show the Connect widget to re-authorize. |
ScopeReauthRequiredError | The app added a required scope after the user connected. | Show the Connect widget to re-authorize with the new scope set. |
PolicyViolationError | A policy denied the call (time-of-day, IP, rate). | Inspect e.policy_error. See Policies. |