Connect GitHub before summarizing pull requests.
`;
17
const authButtonLabel = connected ? "Reconnect GitHub" : "Connect GitHub";
18
19
return `
20
21
22
${connectedBanner}
23
{
4
sub: string
5
email: string
6
email_verified: boolean
7
name: string
8
given_name: string
9
family_name: string
10
picture: string
11
oid: string // organization_id
12
}
13
14
export default function Scalekit(
15
options: OAuthUserConfig
& {
16
issuer: string
17
organizationId?: string
18
connectionId?: string
19
domain?: string
20
}
21
): OAuthConfig
{
22
const { issuer, organizationId, connectionId, domain } = options
23
24
return {
25
id: "scalekit",
26
name: "Scalekit",
27
type: "oidc",
28
issuer,
29
authorization: {
30
params: {
31
scope: "openid email profile",
32
...(connectionId && { connection_id: connectionId }),
33
...(organizationId && { organization_id: organizationId }),
34
...(domain && { domain }),
35
},
36
},
37
profile(profile) {
38
return {
39
id: profile.sub,
40
name: profile.name ?? `${profile.given_name} ${profile.family_name}`,
41
email: profile.email,
42
image: profile.picture ?? null,
43
}
44
},
45
style: { bg: "#6f42c1", text: "#fff" },
46
options,
47
}
48
}
```
After PR #13392 merges, replace the local import with:
```typescript
1
import Scalekit from "next-auth/providers/scalekit"
```
### 4. Configure `auth.ts`
[Section titled “4. Configure auth.ts”](#4-configure-authts)
Create `auth.ts` in your project root:
```typescript
1
import NextAuth from "next-auth"
2
import Scalekit from "./providers/scalekit" // → "next-auth/providers/scalekit" after PR #13392
3
4
export const { handlers, auth, signIn, signOut } = NextAuth({
5
providers: [
6
Scalekit({
7
issuer: process.env.AUTH_SCALEKIT_ISSUER!,
8
clientId: process.env.AUTH_SCALEKIT_ID!,
9
clientSecret: process.env.AUTH_SCALEKIT_SECRET!,
10
// Routing: set one of these (see step 7 for strategy)
11
connectionId: process.env.AUTH_SCALEKIT_CONNECTION_ID,
12
}),
13
],
14
basePath: "/auth",
15
session: { strategy: "jwt" },
16
})
```
`basePath: "/auth"` is required to match the redirect URI you registered in step 1. Without it, Auth.js uses `/api/auth` and the Scalekit callback will fail.
### 5. Set environment variables
[Section titled “5. Set environment variables”](#5-set-environment-variables)
.env.local
```bash
1
# Generate with: npx auth secret
2
AUTH_SECRET=
3
4
# From Scalekit dashboard → API Keys
5
AUTH_SCALEKIT_ISSUER=https://yourenv.scalekit.dev
6
AUTH_SCALEKIT_ID=skc_...
7
AUTH_SCALEKIT_SECRET=
8
9
# Connection ID for development routing (conn_...)
10
# In production, resolve this dynamically per tenant — see step 7
11
AUTH_SCALEKIT_CONNECTION_ID=conn_...
```
`AUTH_SECRET` is not optional. Auth.js uses it to sign JWTs and encrypt session cookies. Missing it causes sign-in to fail silently.
### 6. Wire up route handlers
[Section titled “6. Wire up route handlers”](#6-wire-up-route-handlers)
Create `app/auth/[...nextauth]/route.ts`:
```typescript
1
import { handlers } from "@/auth"
2
export const { GET, POST } = handlers
```
This exposes `GET /auth/callback/scalekit` and `POST /auth/signout` — the endpoints Auth.js needs. The directory must be `app/auth/` (not `app/api/auth/`) to match the `basePath` you configured.
### 7. SSO routing strategies
[Section titled “7. SSO routing strategies”](#7-sso-routing-strategies)
Scalekit resolves which IdP connection to activate using these params (highest to lowest precedence):
```typescript
1
Scalekit({
2
issuer: process.env.AUTH_SCALEKIT_ISSUER!,
3
clientId: process.env.AUTH_SCALEKIT_ID!,
4
clientSecret: process.env.AUTH_SCALEKIT_SECRET!,
5
6
// Option A — exact connection (dev / single-tenant use)
7
connectionId: "conn_...",
8
9
// Option B — org's active connection (multi-tenant: look up org from user's DB record)
10
organizationId: "org_...",
11
12
// Option C — resolve org from email domain (useful at login prompt)
13
domain: "acme.com",
14
})
```
In production, don’t hardcode these values. Store `organizationId` or `connectionId` per tenant in your database, then construct the `signIn()` call dynamically based on the authenticated user’s org:
```typescript
1
// Example: look up org at sign-in time
2
const org = await db.organizations.findByDomain(emailDomain)
3
4
await signIn("scalekit", {
5
organizationId: org.scalekitOrgId,
6
redirectTo: "/dashboard",
7
})
```
### 8. Trigger sign-in and read the session
[Section titled “8. Trigger sign-in and read the session”](#8-trigger-sign-in-and-read-the-session)
A server component reads the session, and a sign-in form triggers the flow:
app/page.tsx
```typescript
1
import { auth, signIn } from "@/auth"
2
3
export default async function Home() {
4
const session = await auth()
5
6
if (session) {
7
return (
8
9
Signed in as {session.user?.email}
10
11
)
12
}
13
14
return (
15
23
)
24
}
```
`session.user` includes `name`, `email`, and `image` normalized from the Scalekit OIDC profile.
## Testing
[Section titled “Testing”](#testing)
1. Run `pnpm dev` and visit `http://localhost:3000`.
2. Click **Sign in with SSO** — you should be redirected to your IdP’s login page.
3. Complete authentication and confirm you land back on your app.
4. Check the session at `http://localhost:3000/api/auth/session` or read it from a server component — you should see `user.email` populated.
If the redirect fails immediately, enable debug logging to trace the OIDC callback:
```bash
1
AUTH_DEBUG=true pnpm dev
```
## Common mistakes
[Section titled “Common mistakes”](#common-mistakes)
1. **Wrong redirect URI** — registering `/api/auth/callback/scalekit` instead of `/auth/callback/scalekit`. This guide sets `basePath: "/auth"` (a custom override, not the v5 default — the default remains `/api/auth`). The URI in Scalekit’s dashboard must match the callback path Auth.js actually uses.
2. **Missing `AUTH_SECRET`** — sign-in appears to start but fails on the callback with no visible error. Always set `AUTH_SECRET`. Generate one with `npx auth secret`.
3. **Hardcoding `connectionId` in production** — works in development, breaks for every other tenant. Store connection identifiers per-organization in your database and resolve them at runtime.
4. **Missing `basePath` in `auth.ts`** — if you omit `basePath: "/auth"`, Auth.js defaults to `/api/auth`. Your route handler must be at `app/api/auth/[...nextauth]/route.ts` and your redirect URI must use `/api/auth/callback/scalekit`. Pick one and be consistent.
5. **Using the wrong import path** — `next-auth/providers/scalekit` only resolves after PR #13392 merges. Until then, the local file at `./providers/scalekit` is the correct import.
## Production notes
[Section titled “Production notes”](#production-notes)
* **Rotate secrets without code changes** — update `AUTH_SCALEKIT_SECRET` in your environment configuration; Scalekit handles IdP certificate rotation automatically.
* **Dynamic connection routing** — store `organizationId` or `connectionId` per tenant in your database. Resolve at sign-in time based on the user’s email domain or their existing tenant membership.
* **Debug OIDC callback issues** — set `AUTH_DEBUG=true` temporarily in production to emit detailed callback traces. Remove it after diagnosing.
* **Session persistence** — JWT sessions (the default) work without a database. If you need server-side session invalidation, add an Auth.js adapter (e.g. Prisma, Drizzle) and switch to `strategy: "database"`.
* **Scalekit handles IdP complexity** — certificate rotation, SAML metadata updates, and attribute mapping changes happen in the Scalekit dashboard without touching your code.
## Next steps
[Section titled “Next steps”](#next-steps)
* [scalekit-developers/scalekit-authjs-example](https://github.com/scalekit-developers/scalekit-authjs-example) — full working repo for this cookbook
* [Auth.js PR #13392](https://github.com/nextauthjs/next-auth/pull/13392) — track native Scalekit provider availability
* [Scalekit SSO routing documentation](https://docs.scalekit.com/sso/quickstart) — full reference for `connection_id`, `organization_id`, and `domain` routing params
* [Auth.js adapters](https://authjs.dev/getting-started/database) — add database-backed sessions for server-side invalidation
* [Scalekit organization management API](https://docs.scalekit.com/apis) — look up `organizationId` dynamically from your tenant records
---
# DOCUMENT BOUNDARY
---
# Add Scalekit hosted auth to a Next.js app
> Wire Scalekit hosted login into the Next.js App Router with server-side sessions, transparent token refresh, and logout.
Scalekit’s [Full Stack Auth journey](/authenticate/fsa/quickstart/) shows the hosted-login flow with Express, Flask, Gin, and Spring. None of those map cleanly onto the Next.js App Router, where there is no long-lived `req`/`res` pair: authentication runs across Route Handlers, Server Components, and middleware, and sessions live in cookies you set from the server.
This cookbook ports the complete flow to Next.js 15 (App Router): redirect users to Scalekit’s hosted login, exchange the authorization code on a callback Route Handler, store tokens in `HttpOnly` cookies, validate and refresh them on every request, and sign users out cleanly. You get enterprise SSO, social login, and passwordless out of the box, because the hosted page handles every method you enable in the dashboard.
## The problem
[Section titled “The problem”](#the-problem)
You want production-grade authentication in a Next.js App Router app, and you have decided to use Scalekit’s hosted login page so you don’t build or maintain login UI. Three things make this non-trivial:
* **No `req`/`res` lifecycle.** The Express examples set cookies on a response object. In the App Router you set cookies through the `cookies()` API and `NextResponse`, in different files for different stages of the flow.
* **The Edge runtime can’t run the Node SDK.** Middleware runs on the Edge runtime by default. The Scalekit Node SDK depends on Node APIs, so token validation belongs in the Node.js runtime, not in default middleware.
* **Refresh tokens rotate.** Scalekit issues a new refresh token every time you redeem one. If you store tokens carelessly, a refresh races itself and logs the user out.
## The approach
[Section titled “The approach”](#the-approach)
Keep every token operation on the server and give each stage of the flow its own file:
| Stage | File | Runtime |
| ------------------------------------ | --------------------------------- | ------- |
| Build the Scalekit client once | `lib/scalekit.ts` | Node.js |
| Read and write session cookies | `lib/session.ts` | Node.js |
| Start login (redirect to Scalekit) | `app/login/route.ts` | Node.js |
| Handle the callback (code exchange) | `app/api/callback/route.ts` | Node.js |
| Validate and refresh on each request | `lib/session.ts` → `getSession()` | Node.js |
| Sign out | `app/logout/route.ts` | Node.js |
Validate the session inside Server Components and Route Handlers — both run on the Node.js runtime — instead of inside Edge middleware. Use middleware only as a lightweight gate that checks for the presence of a session cookie.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* A Next.js 15 app using the App Router.
* A Scalekit account with an **Environment URL**, **Client ID**, and **Client Secret** from **Dashboard > Developers > API Credentials**.
* `http://localhost:3000/api/callback` registered under **Dashboard > Authentication > Redirects > Allowed callback URLs**, and `http://localhost:3000/login` registered as a **Post logout URL**.
Install the SDK:
Terminal
```bash
pnpm add @scalekit-sdk/node
```
Add your credentials to `.env.local`:
.env.local
```bash
SCALEKIT_ENV_URL="https://your-subdomain.scalekit.com"
SCALEKIT_CLIENT_ID="skc_..."
SCALEKIT_CLIENT_SECRET="..." # Never expose this to the browser. Server-only.
SESSION_COOKIE_SECRET="a-32-byte-random-string-for-cookie-encryption"
```
## Create the Scalekit client
[Section titled “Create the Scalekit client”](#create-the-scalekit-client)
Instantiate the client once and reuse it. Reading credentials from the environment keeps the secret out of your bundle, and a module-level singleton avoids reconnecting on every request.
lib/scalekit.ts
```ts
1
import { Scalekit } from '@scalekit-sdk/node';
2
3
// Security: credentials come from server-only env vars. The client secret must
4
// never reach the browser, so this module is only ever imported in server code.
5
export const scalekit = new Scalekit(
6
process.env.SCALEKIT_ENV_URL!,
7
process.env.SCALEKIT_CLIENT_ID!,
8
process.env.SCALEKIT_CLIENT_SECRET!,
9
);
10
11
export const REDIRECT_URI = 'http://localhost:3000/api/callback';
```
## Start the login flow
[Section titled “Start the login flow”](#start-the-login-flow)
Generate a `state` value, store it in a short-lived cookie to defend against CSRF, and redirect the browser to Scalekit’s hosted login page. Include `offline_access` in the scopes so Scalekit returns a refresh token.
app/login/route.ts
```ts
1
import { randomBytes } from 'node:crypto';
2
import { cookies } from 'next/headers';
3
import { redirect } from 'next/navigation';
4
import { scalekit, REDIRECT_URI } from '@/lib/scalekit';
5
6
export async function GET() {
7
// Security: a random state ties the callback back to this browser. Without it,
8
// an attacker could replay a callback and complete login as someone else (CSRF).
9
const state = randomBytes(32).toString('hex');
10
11
const cookieStore = await cookies();
12
cookieStore.set('sk_oauth_state', state, {
13
httpOnly: true, // Block JavaScript access to mitigate XSS token theft.
14
secure: process.env.NODE_ENV === 'production', // HTTPS-only outside local dev.
15
sameSite: 'lax',
16
maxAge: 60 * 10, // The state is only needed for the next 10 minutes.
17
path: '/',
18
});
19
20
const authorizationUrl = scalekit.getAuthorizationUrl(REDIRECT_URI, {
21
scopes: ['openid', 'profile', 'email', 'offline_access'],
22
state,
23
});
24
25
redirect(authorizationUrl);
26
}
```
## Handle the callback
[Section titled “Handle the callback”](#handle-the-callback)
After the user authenticates, Scalekit redirects back with a `code` and your `state`. Validate the `state`, exchange the code for tokens with `authenticateWithCode`, then store the tokens in `HttpOnly` cookies.
app/api/callback/route.ts
```ts
1
import { cookies } from 'next/headers';
2
import { NextRequest, NextResponse } from 'next/server';
3
import { scalekit, REDIRECT_URI } from '@/lib/scalekit';
4
import { setSessionCookies } from '@/lib/session';
5
6
export async function GET(request: NextRequest) {
7
const { searchParams } = request.nextUrl;
8
const code = searchParams.get('code');
9
const state = searchParams.get('state');
10
const error = searchParams.get('error');
11
12
const cookieStore = await cookies();
13
const storedState = cookieStore.get('sk_oauth_state')?.value;
14
cookieStore.delete('sk_oauth_state'); // Use the state only once.
15
16
if (error) {
17
return NextResponse.redirect(new URL('/login?error=auth_failed', request.url));
18
}
19
20
// Security: reject the callback unless the returned state matches the one we
21
// issued. A mismatch means the response did not originate from our redirect.
22
if (!code || !state || state !== storedState) {
23
return NextResponse.redirect(new URL('/login?error=invalid_state', request.url));
24
}
25
26
try {
27
const { user, idToken, accessToken, refreshToken } =
28
await scalekit.authenticateWithCode(code, REDIRECT_URI);
29
30
const response = NextResponse.redirect(new URL('/dashboard', request.url));
31
setSessionCookies(response, { idToken, accessToken, refreshToken });
32
return response;
33
} catch {
34
return NextResponse.redirect(new URL('/login?error=exchange_failed', request.url));
35
}
36
}
```
A refresh token requires offline\_access
Scalekit only returns `refreshToken` when the authorization request includes the `offline_access` scope. If you omit it, sessions end as soon as the access token expires because there is nothing to refresh.
## Store and read the session
[Section titled “Store and read the session”](#store-and-read-the-session)
Centralize cookie handling so login, refresh, and logout stay consistent. Keep tokens in `HttpOnly`, `Secure` cookies, and scope the refresh token to a narrow path so it is sent only when you need it.
lib/session.ts
```ts
1
import { cookies } from 'next/headers';
2
import type { NextResponse } from 'next/server';
3
import { scalekit } from '@/lib/scalekit';
4
5
type Tokens = { idToken: string; accessToken: string; refreshToken: string };
6
7
const COOKIE_BASE = {
8
httpOnly: true, // Tokens are never readable from client-side JavaScript (XSS defense).
9
secure: process.env.NODE_ENV === 'production',
10
sameSite: 'lax' as const,
11
};
12
13
export function setSessionCookies(response: NextResponse, tokens: Tokens) {
14
response.cookies.set('sk_id_token', tokens.idToken, { ...COOKIE_BASE, path: '/' });
15
rotateTokens(response, tokens);
16
}
17
18
// Refresh returns a new access and refresh token but not a new ID token, so this
19
// updates only those two cookies and leaves the existing ID token in place.
20
export function rotateTokens(
21
response: NextResponse,
22
tokens: { accessToken: string; refreshToken: string },
23
) {
24
response.cookies.set('sk_access_token', tokens.accessToken, { ...COOKIE_BASE, path: '/' });
25
// Security: scope the refresh token to the refresh endpoint only, so it is not
26
// attached to every request. This shrinks the window for token exfiltration.
27
response.cookies.set('sk_refresh_token', tokens.refreshToken, {
28
...COOKIE_BASE,
29
path: '/api/refresh',
30
});
31
}
32
33
/**
34
* Returns the authenticated user, or null. Call this from Server Components and
35
* Route Handlers (Node.js runtime) — never from Edge middleware, because the
36
* Scalekit SDK needs Node APIs that the Edge runtime does not provide.
37
*/
38
export async function getSession() {
39
const cookieStore = await cookies();
40
const accessToken = cookieStore.get('sk_access_token')?.value;
41
if (!accessToken) return null;
42
43
const isValid = await scalekit.validateAccessToken(accessToken);
44
if (!isValid) return null;
45
46
// validateToken returns the decoded claims once the signature and expiry pass.
47
const claims = await scalekit.validateToken(accessToken);
48
return { sub: claims.sub, email: claims.email };
49
}
```
## Refresh tokens transparently
[Section titled “Refresh tokens transparently”](#refresh-tokens-transparently)
When the access token expires, redeem the refresh token for a new pair. Because Scalekit rotates refresh tokens, write the new refresh token back immediately and discard the old one.
app/api/refresh/route.ts
```ts
1
import { cookies } from 'next/headers';
2
import { NextResponse } from 'next/server';
3
import { scalekit } from '@/lib/scalekit';
4
import { rotateTokens } from '@/lib/session';
5
6
export async function POST() {
7
const cookieStore = await cookies();
8
const refreshToken = cookieStore.get('sk_refresh_token')?.value;
9
if (!refreshToken) {
10
return NextResponse.json({ error: 'no_session' }, { status: 401 });
11
}
12
13
try {
14
const tokens = await scalekit.refreshAccessToken(refreshToken);
15
const response = NextResponse.json({ ok: true });
16
// Security: persist the rotated refresh token. Replaying the old one fails,
17
// which is how Scalekit detects a stolen, reused token.
18
rotateTokens(response, tokens);
19
return response;
20
} catch {
21
return NextResponse.json({ error: 'refresh_failed' }, { status: 401 });
22
}
23
}
```
## Protect routes
[Section titled “Protect routes”](#protect-routes)
Read the session in a Server Component and redirect unauthenticated visitors. This runs on the Node.js runtime, so the SDK validation works.
app/dashboard/page.tsx
```tsx
1
import { redirect } from 'next/navigation';
2
import { getSession } from '@/lib/session';
3
4
export default async function DashboardPage() {
5
const session = await getSession();
6
if (!session) redirect('/login');
7
8
return Welcome, {session.email}
;
9
}
```
For a coarse, fast gate across many routes, add middleware that only checks whether a session cookie exists. Keep real validation in the page or Route Handler.
middleware.ts
```ts
1
import { NextRequest, NextResponse } from 'next/server';
2
3
export function middleware(request: NextRequest) {
4
// Presence check only — middleware runs on the Edge runtime and cannot call the
5
// Scalekit SDK. getSession() does the cryptographic validation downstream.
6
const hasSession = request.cookies.has('sk_access_token');
7
if (!hasSession) {
8
return NextResponse.redirect(new URL('/login', request.url));
9
}
10
return NextResponse.next();
11
}
12
13
export const config = { matcher: ['/dashboard/:path*'] };
```
## Sign out
[Section titled “Sign out”](#sign-out)
Build the Scalekit logout URL, clear your cookies, and redirect the browser to Scalekit so the server-side session ends too. Pass the ID token as `idTokenHint` before you clear it.
app/logout/route.ts
```ts
1
import { cookies } from 'next/headers';
2
import { NextResponse } from 'next/server';
3
import { scalekit } from '@/lib/scalekit';
4
5
export async function GET() {
6
const cookieStore = await cookies();
7
const idToken = cookieStore.get('sk_id_token')?.value;
8
9
const logoutUrl = scalekit.getLogoutUrl({
10
idTokenHint: idToken,
11
postLogoutRedirectUri: 'http://localhost:3000/login',
12
});
13
14
const response = NextResponse.redirect(logoutUrl);
15
// Clear local cookies after building the logout URL, so the ID token is still
16
// available to tell Scalekit which session to end.
17
response.cookies.delete('sk_access_token');
18
response.cookies.delete('sk_id_token');
19
response.cookies.delete('sk_refresh_token');
20
return response;
21
}
```
Logout must be a browser redirect
Redirect the browser to the logout URL rather than calling it from server code. The redirect carries Scalekit’s session cookie, which lets Scalekit identify and end the correct session.
## Verify it works
[Section titled “Verify it works”](#verify-it-works)
1. Start the app with `pnpm dev` and open `http://localhost:3000/dashboard`. The middleware redirects you to `/login`.
2. Visit `http://localhost:3000/login`. The browser lands on Scalekit’s hosted login page showing every method you enabled in the dashboard.
3. Sign in. Scalekit returns to `/api/callback`, which sets the session cookies and forwards you to `/dashboard`, where your email renders.
4. Inspect cookies in your browser devtools. Confirm `sk_access_token`, `sk_id_token`, and `sk_refresh_token` are present and marked `HttpOnly`.
5. Open `http://localhost:3000/logout`. Your cookies clear, Scalekit ends the session, and you return to `/login`.
## Production notes
[Section titled “Production notes”](#production-notes)
* **Encrypt cookie values.** This recipe stores raw tokens for clarity. In production, encrypt them with `SESSION_COOKIE_SECRET` (for example with [`jose`](https://github.com/panva/jose)) before writing, and decrypt on read.
* **Drive refresh from the client.** Call `POST /api/refresh` from a client effect shortly before the access token expires, or retry once on a `401`, so sessions renew without a full re-login.
* **Use absolute redirect URLs per environment.** Replace the hard-coded `localhost` URLs with an environment variable, and register each environment’s callback and post-logout URLs in the dashboard.
When you are ready to ship, walk the [production readiness checklist](/authenticate/launch-checklist/). To inspect what the access token carries, see [ID token claims](/guides/idtoken-claims/).
---
# DOCUMENT BOUNDARY
---
# Building a Custom Organization Switcher
> Learn how to build your own organization switcher UI for complete control over multi-tenant user experiences.
When users belong to multiple organizations, the default Scalekit organization switcher handles most use cases. However, some applications require deeper integration—a custom switcher embedded directly in your app’s navigation, or a specialized UI that matches your design system.
This guide shows you how to build your own organization switcher using Scalekit’s APIs.
## Why build a custom switcher?
[Section titled “Why build a custom switcher?”](#why-build-a-custom-switcher)
The default Scalekit-hosted switcher works well for most scenarios. Build a custom switcher when you need:
* **In-app navigation**: Users switch organizations without leaving your application
* **Custom branding**: The switcher matches your application’s design language
* **Specialized workflows**: Your app needs org-specific logic during switches
* **Reduced redirects**: Avoid sending users through the authentication flow for every switch
## How the custom switcher works
[Section titled “How the custom switcher works”](#how-the-custom-switcher-works)
Your application handles the entire switching flow:
1. User authenticates through Scalekit and receives a session
2. Your app fetches the user’s organizations via the User Sessions API
3. You render your own organization selector UI
4. When a user selects an organization, your app updates the active context
This approach gives you full control over the UI and routing, but requires you to manage session state and organization context within your application.
## Fetch user organizations
[Section titled “Fetch user organizations”](#fetch-user-organizations)
The User Sessions API returns the `authenticated_organizations` field containing all organizations the user can access. Use this data to populate your switcher UI.
* Node.js
Express.js
```javascript
1
// Use case: Get user's organizations for your switcher UI
2
// Security: Always validate session ownership before returning org data
3
const session = await scalekit.session.getSession(sessionId);
4
5
// Extract organizations from the session response
6
const organizations = session.authenticated_organizations || [];
7
8
// Render your organization switcher with this data
9
res.json({ organizations });
```
* Python
Flask
```python
1
# Use case: Get user's organizations for your switcher UI
2
# Security: Always validate session ownership before returning org data
3
session = scalekit_client.sessions.get_session(session_id)
4
5
# Extract organizations from the session response
6
organizations = session.get('authenticated_organizations', [])
7
8
# Render your organization switcher with this data
9
return jsonify({'organizations': organizations})
```
* Go
Gin
```go
1
// Use case: Get user's organizations for your switcher UI
2
// Security: Always validate session ownership before returning org data
3
session, err := scalekitClient.Session().GetSession(ctx, sessionId)
4
if err != nil {
5
return err
6
}
7
8
// Extract organizations from the session response
9
organizations := session.AuthenticatedOrganizations
10
11
// Render your organization switcher with this data
12
c.JSON(http.StatusOK, gin.H{"organizations": organizations})
```
* Java
Spring
```java
1
// Use case: Get user's organizations for your switcher UI
2
// Security: Always validate session ownership before returning org data
3
Session session = scalekitClient.sessions().getSession(sessionId);
4
5
// Extract organizations from the session response
6
List organizations = session.getAuthenticatedOrganizations();
7
8
// Render your organization switcher with this data
9
return ResponseEntity.ok(Map.of("organizations", organizations));
```
The response includes organization IDs, names, and metadata for each organization the user can access.
## Add domain context
[Section titled “Add domain context”](#add-domain-context)
Enhance your switcher by displaying which domains are associated with each organization. Use the Domains API to fetch this information.
```javascript
1
// Example: Fetch domains for an organization
2
const domains = await scalekit.domain.listDomains('org_123');
3
4
// Display "@acme.com" next to the organization name in your UI
```
This helps users quickly identify the correct organization, especially when they belong to organizations with similar names.
## Handle organization selection
[Section titled “Handle organization selection”](#handle-organization-selection)
When a user selects an organization in your custom switcher, update your application’s context. Store the active organization ID in session storage or a cookie, then use it for subsequent API calls.
* Node.js
Express.js
```javascript
1
// Use case: Store selected organization and fetch org-specific data
2
app.post('/api/select-organization', async (req, res) => {
3
const { organizationId } = req.body;
4
const sessionId = req.session.scalekitSessionId;
5
6
// Security: Verify the user belongs to this organization
7
const session = await scalekit.session.getSession(sessionId);
8
const hasAccess = session.authenticated_organizations.some(
9
org => org.id === organizationId
10
);
11
12
if (!hasAccess) {
13
return res.status(403).json({ error: 'Unauthorized' });
14
}
15
16
// Store the active organization in the user's session
17
req.session.activeOrganizationId = organizationId;
18
19
res.json({ success: true });
20
});
```
* Python
Flask
```python
1
# Use case: Store selected organization and fetch org-specific data
2
@app.route('/api/select-organization', methods=['POST'])
3
def select_organization():
4
data = request.get_json()
5
organization_id = data.get('organizationId')
6
session_id = session.get('scalekit_session_id')
7
8
# Security: Verify the user belongs to this organization
9
user_session = scalekit_client.sessions.get_session(session_id)
10
has_access = any(
11
org['id'] == organization_id
12
for org in user_session.get('authenticated_organizations', [])
13
)
14
15
if not has_access:
16
return jsonify({'error': 'Unauthorized'}), 403
17
18
# Store the active organization in the user's session
19
session['active_organization_id'] = organization_id
20
21
return jsonify({'success': True})
```
* Go
Gin
```go
1
// Use case: Store selected organization and fetch org-specific data
2
func SelectOrganization(c *gin.Context) {
3
var req struct {
4
OrganizationID string `json:"organizationId"`
5
}
6
if err := c.BindJSON(&req); err != nil {
7
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
8
return
9
}
10
11
sessionID := c.GetString("scalekitSessionID")
12
13
// Security: Verify the user belongs to this organization
14
session, err := scalekitClient.Session().GetSession(ctx, sessionID)
15
if err != nil {
16
c.JSON(http.StatusInternalServerError, gin.H{"error": "Session error"})
17
return
18
}
19
20
hasAccess := false
21
for _, org := range session.AuthenticatedOrganizations {
22
if org.ID == req.OrganizationID {
23
hasAccess = true
24
break
25
}
26
}
27
28
if !hasAccess {
29
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
30
return
31
}
32
33
// Store the active organization in the user's session
34
c.SetCookie("activeOrganizationID", req.OrganizationID, 3600, "/", "", true, true)
35
36
c.JSON(http.StatusOK, gin.H{"success": true})
37
}
```
* Java
Spring
```java
1
// Use case: Store selected organization and fetch org-specific data
2
@PostMapping("/api/select-organization")
3
public ResponseEntity> selectOrganization(
4
@RequestBody Map request,
5
HttpSession httpSession
6
) {
7
String organizationId = request.get("organizationId");
8
String sessionId = (String) httpSession.getAttribute("scalekitSessionId");
9
10
// Security: Verify the user belongs to this organization
11
Session session = scalekitClient.sessions().getSession(sessionId);
12
boolean hasAccess = session.getAuthenticatedOrganizations().stream()
13
.anyMatch(org -> org.getId().equals(organizationId));
14
15
if (!hasAccess) {
16
return ResponseEntity.status(HttpStatus.FORBIDDEN)
17
.body(Map.of("error", "Unauthorized"));
18
}
19
20
// Store the active organization in the user's session
21
httpSession.setAttribute("activeOrganizationId", organizationId);
22
23
return ResponseEntity.ok(Map.of("success", true));
24
}
```
Always verify that the user actually belongs to the organization they’re attempting to switch to. The `authenticated_organizations` array from the session is your source of truth for access control.
## When to use the hosted switcher instead
[Section titled “When to use the hosted switcher instead”](#when-to-use-the-hosted-switcher-instead)
The default Scalekit-hosted switcher is the right choice when:
* You want the quickest implementation with minimal code
* Your application doesn’t require in-app organization switching
* You’re okay with users navigating through the authentication flow to switch organizations
Build a custom switcher when user experience requirements demand deeper integration with your application’s UI and routing.
You may refer to our [Sample Org Swithcer ](https://github.com/scalekit-inc/Nextjs-Django-Org-Switcher-Example/tree/main)application to better understand how the API calls enable this custom org switcher that is embedded inside your application.
---
# DOCUMENT BOUNDARY
---
# Implement passwordless auth in Next.js 15
> Add magic link and OTP authentication to your Next.js application using Scalekit's headless API.
Next.js 15’s App Router expects authentication to be server-first: tokens generated on the server, verification happening in Route Handlers or Server Actions, and sessions stored in HttpOnly cookies. If you’re building passwordless authentication (magic links + OTP), traditional client-side SDKs won’t work properly with this model.
This cookbook shows you how to implement passwordless auth that works natively with Next.js 15’s architecture using Scalekit’s headless API.
## The problem
[Section titled “The problem”](#the-problem)
You want passwordless authentication in Next.js 15 but face these challenges:
* **Client-side SDKs break App Router patterns** - They expect browser-side token handling, which violates server-first principles
* **Vendor UIs don’t match your design** - Pre-built login pages force you to compromise on branding
* **DIY is complex** - Building secure token generation, email delivery, verification, and session management from scratch is a significant lift
* **Cross-device failures** - Magic links often break when users switch devices or email clients strip parameters
## Who needs this
[Section titled “Who needs this”](#who-needs-this)
This cookbook is for you if:
* ✅ You’re building a Next.js 15 application using App Router
* ✅ You want passwordless authentication (magic links, OTP, or both)
* ✅ You need full control over your login UI and email design
* ✅ You don’t want to migrate your existing user database
* ✅ You require server-side security for compliance
You **don’t** need this if:
* ❌ You’re happy with vendor-hosted login pages
* ❌ You’re using Next.js Pages Router (not App Router)
* ❌ You prefer traditional username/password authentication
## The solution
[Section titled “The solution”](#the-solution)
Scalekit’s passwordless API provides three server-side methods that integrate directly with Next.js 15’s architecture:
1. **`sendPasswordlessEmail()`** - Generates and sends magic link/OTP to user’s email
2. **`verifyPasswordlessEmail()`** - Validates the token/code and returns verified identity
3. **`resendPasswordlessEmail()`** - Issues a fresh credential if the first expires
All security logic stays server-side, works with Server Actions and Route Handlers, and integrates with Edge Middleware for route protection.
## Implementation
[Section titled “Implementation”](#implementation)
### 1. Configure Scalekit dashboard
[Section titled “1. Configure Scalekit dashboard”](#1-configure-scalekit-dashboard)
Enable passwordless authentication in your [Scalekit dashboard](https://app.scalekit.com/):
1. Navigate to **Authentication → Passwordless**
2. Select **Magic Link + Verification Code** for maximum reliability
3. Set **Expiry Period** (e.g., 600 seconds for 10-minute lifetime)
4. Enable **Enforce same browser origin** to prevent link hijacking
5. (Optional) Enable **Regenerate credentials on resend** to invalidate old links
### 2. Install dependencies and configure environment
[Section titled “2. Install dependencies and configure environment”](#2-install-dependencies-and-configure-environment)
```bash
1
npm install @scalekit-sdk/node jsonwebtoken
```
Create `.env.local`:
```bash
1
SCALEKIT_ENVIRONMENT_URL=env_xxxx
2
SCALEKIT_CLIENT_ID=skc_xxx
3
SCALEKIT_CLIENT_SECRET=your_secret
4
APP_URL=http://localhost:3000
5
JWT_SECRET=your_jwt_secret
```
### 3. Create session management utilities
[Section titled “3. Create session management utilities”](#3-create-session-management-utilities)
Create `lib/session-store.ts` to handle server-side session creation:
```typescript
1
import jwt from 'jsonwebtoken';
2
import { cookies } from 'next/headers';
3
4
const COOKIE = 'session';
5
const SECRET = process.env.JWT_SECRET!;
6
7
export function createSession(email: string) {
8
const token = jwt.sign({ email }, SECRET, { expiresIn: '7d' });
9
cookies().set(COOKIE, token, {
10
httpOnly: true,
11
secure: process.env.NODE_ENV === 'production',
12
sameSite: 'lax',
13
path: '/',
14
maxAge: 60 * 60 * 24 * 7,
15
});
16
}
17
18
export function readSessionEmail(): string | null {
19
const token = cookies().get(COOKIE)?.value;
20
if (!token) return null;
21
22
try {
23
const decoded = jwt.verify(token, SECRET) as { email: string };
24
return decoded.email;
25
} catch {
26
return null;
27
}
28
}
29
30
export function clearSession() {
31
cookies().delete(COOKIE);
32
}
```
### 4. Create send email endpoint
[Section titled “4. Create send email endpoint”](#4-create-send-email-endpoint)
Create `app/api/auth/send-passwordless/route.ts`:
```typescript
1
import Scalekit from '@scalekit-sdk/node';
2
import { NextRequest, NextResponse } from 'next/server';
3
4
const scalekit = new Scalekit(
5
process.env.SCALEKIT_ENVIRONMENT_URL!,
6
process.env.SCALEKIT_CLIENT_ID!,
7
process.env.SCALEKIT_CLIENT_SECRET!
8
);
9
10
export async function POST(req: NextRequest) {
11
const { email } = await req.json();
12
13
try {
14
const response = await scalekit.passwordless.sendPasswordlessEmail(email, {
15
template: 'SIGNIN',
16
expiresIn: 600, // 10 minutes
17
state: crypto.randomUUID(),
18
magiclinkAuthUri: `${process.env.APP_URL}/api/auth/verify`,
19
});
20
21
return NextResponse.json({
22
authRequestId: response.authRequestId,
23
expiresAt: response.expiresAt,
24
});
25
} catch (error) {
26
return NextResponse.json(
27
{ error: 'Failed to send email' },
28
{ status: 500 }
29
);
30
}
31
}
```
### 5. Create verification endpoint
[Section titled “5. Create verification endpoint”](#5-create-verification-endpoint)
Create `app/api/auth/verify/route.ts` with both GET (magic link) and POST (OTP) handlers:
```typescript
1
import Scalekit from '@scalekit-sdk/node';
2
import { NextRequest, NextResponse } from 'next/server';
3
import { createSession } from '@/lib/session-store';
4
5
const scalekit = new Scalekit(
6
process.env.SCALEKIT_ENVIRONMENT_URL!,
7
process.env.SCALEKIT_CLIENT_ID!,
8
process.env.SCALEKIT_CLIENT_SECRET!
9
);
10
11
// Magic link verification
12
export async function GET(req: NextRequest) {
13
const url = new URL(req.url);
14
const linkToken = url.searchParams.get('link_token');
15
const authRequestId = url.searchParams.get('auth_request_id') ?? undefined;
16
17
if (!linkToken) {
18
return NextResponse.redirect(
19
new URL('/login?error=missing_token', req.url)
20
);
21
}
22
23
try {
24
const verified = await scalekit.passwordless.verifyPasswordlessEmail(
25
{ linkToken },
26
authRequestId
27
);
28
29
createSession(verified.email);
30
return NextResponse.redirect(new URL('/dashboard', req.url));
31
} catch {
32
return NextResponse.redirect(
33
new URL('/login?error=verification_failed', req.url)
34
);
35
}
36
}
37
38
// OTP verification
39
export async function POST(req: NextRequest) {
40
const { code, authRequestId } = await req.json();
41
42
if (!code || !authRequestId) {
43
return NextResponse.json(
44
{ error: 'Missing required fields' },
45
{ status: 400 }
46
);
47
}
48
49
try {
50
const verified = await scalekit.passwordless.verifyPasswordlessEmail(
51
{ code },
52
authRequestId
53
);
54
55
createSession(verified.email);
56
return NextResponse.json({ success: true });
57
} catch {
58
return NextResponse.json(
59
{ error: 'Invalid or expired code' },
60
{ status: 400 }
61
);
62
}
63
}
```
### 6. Add resend endpoint
[Section titled “6. Add resend endpoint”](#6-add-resend-endpoint)
Create `app/api/auth/resend-passwordless/route.ts`:
```typescript
1
import Scalekit from '@scalekit-sdk/node';
2
import { NextRequest, NextResponse } from 'next/server';
3
4
const scalekit = new Scalekit(
5
process.env.SCALEKIT_ENVIRONMENT_URL!,
6
process.env.SCALEKIT_CLIENT_ID!,
7
process.env.SCALEKIT_CLIENT_SECRET!
8
);
9
10
export async function POST(req: NextRequest) {
11
const { authRequestId } = await req.json();
12
13
if (!authRequestId) {
14
return NextResponse.json(
15
{ error: 'Missing authRequestId' },
16
{ status: 400 }
17
);
18
}
19
20
try {
21
const response = await scalekit.passwordless.resendPasswordlessEmail(
22
authRequestId
23
);
24
25
return NextResponse.json({
26
authRequestId: response.authRequestId,
27
expiresAt: response.expiresAt,
28
});
29
} catch {
30
return NextResponse.json(
31
{ error: 'Resend failed' },
32
{ status: 400 }
33
);
34
}
35
}
```
### 7. Protect routes with middleware
[Section titled “7. Protect routes with middleware”](#7-protect-routes-with-middleware)
Create `middleware.ts` in your project root:
```typescript
1
import { NextRequest, NextResponse } from 'next/server';
2
3
export function middleware(req: NextRequest) {
4
const protectedPath = req.nextUrl.pathname.startsWith('/dashboard');
5
const hasSession = Boolean(req.cookies.get('session')?.value);
6
7
if (protectedPath && !hasSession) {
8
const url = new URL('/login', req.url);
9
url.searchParams.set('next', req.nextUrl.pathname);
10
return NextResponse.redirect(url);
11
}
12
13
return NextResponse.next();
14
}
15
16
export const config = {
17
matcher: ['/dashboard/:path*'],
18
};
```
### 8. Build login UI (example)
[Section titled “8. Build login UI (example)”](#8-build-login-ui-example)
Create `app/login/page.tsx`:
```typescript
1
'use client';
2
3
import { useState } from 'react';
4
import { useRouter } from 'next/navigation';
5
6
export default function LoginPage() {
7
const [email, setEmail] = useState('');
8
const [authRequestId, setAuthRequestId] = useState('');
9
const [showOtp, setShowOtp] = useState(false);
10
const [otp, setOtp] = useState('');
11
const router = useRouter();
12
13
async function handleSendEmail(e: React.FormEvent) {
14
e.preventDefault();
15
16
const res = await fetch('/api/auth/send-passwordless', {
17
method: 'POST',
18
headers: { 'Content-Type': 'application/json' },
19
body: JSON.stringify({ email }),
20
});
21
22
const data = await res.json();
23
setAuthRequestId(data.authRequestId);
24
setShowOtp(true);
25
}
26
27
async function handleVerifyOtp(e: React.FormEvent) {
28
e.preventDefault();
29
30
const res = await fetch('/api/auth/verify', {
31
method: 'POST',
32
headers: { 'Content-Type': 'application/json' },
33
body: JSON.stringify({ code: otp, authRequestId }),
34
});
35
36
if (res.ok) {
37
router.push('/dashboard');
38
}
39
}
40
41
return (
42
43
{!showOtp ? (
44
54
) : (
55
66
)}
67
68
);
69
}
```
## Security features
[Section titled “Security features”](#security-features)
Scalekit enforces these protections automatically:
* **Rate limiting**: 2 emails per minute per address, 5 OTP attempts per 10 minutes
* **Short-lived tokens**: Configure expiry from 60 seconds to 1 hour
* **Same-browser enforcement**: When enabled, links can only be verified from the originating browser
* **HttpOnly sessions**: Tokens never touch client JavaScript
## Error handling
[Section titled “Error handling”](#error-handling)
Map Scalekit errors to user-friendly messages:
```typescript
1
function getErrorMessage(error: string): string {
2
if (error.includes('expired')) {
3
return 'This link has expired. Request a new one.';
4
}
5
if (error.includes('rate')) {
6
return 'Too many attempts. Please try again later.';
7
}
8
if (error.includes('invalid')) {
9
return 'Invalid code. Please check and try again.';
10
}
11
return 'Verification failed. Please try again.';
12
}
```
## Production checklist
[Section titled “Production checklist”](#production-checklist)
Before deploying:
* ✅ Set `secure: true` for session cookies (enforced automatically in production)
* ✅ Configure production Scalekit credentials in environment variables
* ✅ Verify dashboard settings match your security requirements
* ✅ Test magic link + OTP flow on multiple email clients
* ✅ Set up monitoring for authentication errors and rate limit hits
* ✅ Configure custom email templates with your branding
## Complete example
[Section titled “Complete example”](#complete-example)
Full working code is available in the [Scalekit GitHub repository](https://github.com/scalekit-developers/blogops-app-examples/tree/main/nextjs-passwordless-auth).
## Why this approach works
[Section titled “Why this approach works”](#why-this-approach-works)
This implementation:
* **Works natively with App Router** - All sensitive operations are server-side
* **Maintains full UI control** - No vendor widgets or redirects to hosted pages
* **Handles cross-device gracefully** - OTP fallback covers magic link failures
* **Requires no user migration** - Works on top of your existing user store
* **Stays secure by default** - HttpOnly cookies, server-only verification, automatic rate limiting
## Related resources
[Section titled “Related resources”](#related-resources)
* [Scalekit Passwordless Auth Documentation](https://docs.scalekit.com/passwordless/)
* [Next.js 15 App Router Documentation](https://nextjs.org/docs/app)
* [Full tutorial blog post](https://www.scalekit.com/blog/passwordless-authentication-next-js)
---
# DOCUMENT BOUNDARY
---
# Configuring JWT Validation Timeouts in Spring Boot 4.0+
> Fix connection timeout errors when validating Scalekit JWT tokens in Spring Boot 4.0.0 and later versions.
If you’re using Spring Boot 4.0.0 or later and experiencing connection timeout errors when validating JWT tokens from Scalekit, you’ll need to explicitly configure timeout values. This is a known issue affecting Spring Security’s OAuth2 resource server configuration.
## The problem
[Section titled “The problem”](#the-problem)
Your Spring Boot application successfully configures the `issuer-uri` for JWT validation:
```yaml
1
spring:
2
security:
3
oauth2:
4
resourceserver:
5
jwt:
6
issuer-uri: https://auth.scalekit.com
```
But authentication fails with timeout errors like:
```plaintext
1
java.net.SocketTimeoutException: Connect timed out
2
at org.springframework.security.oauth2.jwt.JwtDecoders.fromIssuerLocation
```
## Why this happens
[Section titled “Why this happens”](#why-this-happens)
Starting with Spring Boot 4.0.0, Spring Security changed how it handles HTTP connections during JWT validation:
* **Before 4.0.0**: Spring used default system timeouts (often much longer)
* **After 4.0.0**: Spring enforces strict, short timeout defaults that can be too aggressive for production
When your application starts or validates its first JWT token, Spring Security:
1. Fetches the OpenID Connect discovery document from `issuer-uri`
2. Retrieves the JWKS (JSON Web Key Set) to verify token signatures
3. Caches these for future validations
If these initial requests timeout, authentication fails completely.
## Who needs this fix
[Section titled “Who needs this fix”](#who-needs-this-fix)
This issue specifically affects:
* ✅ Spring Boot applications version **4.0.0 or later**
* ✅ Using `issuer-uri` for JWT validation (not manual `jwk-set-uri`)
* ✅ Production environments with network latency or firewall rules
* ✅ Applications experiencing intermittent authentication failures
You **don’t** need this if:
* ❌ Using Spring Boot 3.x or earlier
* ❌ Manually configuring `jwk-set-uri` instead of `issuer-uri`
* ❌ Already have custom `RestTemplate` or `WebClient` configurations
## The solution
[Section titled “The solution”](#the-solution)
For Spring Security servlet resource servers, there are no properties to configure JWT discovery/JWKS HTTP timeouts. Use a custom `JwtDecoder` bean with `RestOperations` (for example, `RestTemplate`) and explicit timeout values:
```java
1
import org.springframework.context.annotation.Bean;
2
import org.springframework.context.annotation.Configuration;
3
import org.springframework.http.client.SimpleClientHttpRequestFactory;
4
import org.springframework.security.oauth2.jwt.JwtDecoder;
5
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
6
import org.springframework.web.client.RestTemplate;
7
8
@Configuration
9
public class SecurityConfig {
10
11
@Bean
12
public JwtDecoder jwtDecoder() {
13
// Create a RestTemplate with custom timeouts
14
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
15
factory.setConnectTimeout(10000); // 10 seconds
16
factory.setReadTimeout(10000); // 10 seconds
17
18
RestTemplate restTemplate = new RestTemplate(factory);
19
20
// Use the custom RestTemplate for JWT validation
21
return NimbusJwtDecoder
22
.withIssuerLocation("https://auth.scalekit.com")
23
.restOperations(restTemplate)
24
.build();
25
}
26
}
```
This gives you:
* Full control over HTTP client configuration
* Ability to add custom headers or interceptors
* Environment-specific timeout tuning (development: 5000ms, production: 10000–15000ms)
## Verifying the fix
[Section titled “Verifying the fix”](#verifying-the-fix)
After applying the configuration:
1. **Restart your application** - Spring Security initializes the JWT decoder on startup
2. **Test authentication** - Make a request with a valid Scalekit JWT token
3. **Check logs** - You should see successful JWKS retrieval:
```plaintext
1
DEBUG o.s.security.oauth2.jwt.JwtDecoder - Retrieved JWKS from https://auth.scalekit.com/.well-known/jwks.json
```
If you still see timeout errors:
* Verify network connectivity to `auth.scalekit.com`
* Check firewall rules allowing outbound HTTPS
* Increase timeout values if your network has high latency
## When to use standard Spring Security instead
[Section titled “When to use standard Spring Security instead”](#when-to-use-standard-spring-security-instead)
This cookbook addresses a specific Spring Boot 4.0+ timeout issue. For general JWT validation setup:
* Follow the [Spring Security OAuth2 Resource Server documentation](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
* Use Scalekit’s standard Java SDK for token validation if not using Spring Security
* Consider the default `issuer-uri` configuration if you’re not experiencing timeouts
## Related resources
[Section titled “Related resources”](#related-resources)
* [Spring Security OAuth2 Resource Server - JWT Timeouts](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html#oauth2resourceserver-jwt-timeouts)
* [Scalekit API reference](/apis/#tag/sessions)
* [Spring Boot 4.0 Release Notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Release-Notes)
---
# DOCUMENT BOUNDARY
---
# M2M JWT verification with JWKS and OAuth scopes
> How JSON Web Key Sets work with Scalekit, how to use the /keys endpoint to verify machine-to-machine tokens, and how OAuth scopes map to JWT claims for authorization.
When you add OAuth 2.0 client credentials for your APIs, callers receive **JWT access tokens**. Before you trust any claim, you must **verify the signature** using Scalekit’s public keys (**JWKS**). After verification, you **authorize** the request—often by checking **OAuth scopes** carried in the token.
This cookbook explains how JWKS and scopes fit together for Scalekit M2M flows: where keys live, how verification works at a high level, how scopes are defined and stored, and how to enforce them in your service.
## Why JWKS and scopes belong in one place
[Section titled “Why JWKS and scopes belong in one place”](#why-jwks-and-scopes-belong-in-one-place)
* **JWKS answers “is this token real?”** — You use the key identified by `kid` in the JWT header to validate the signature (typically **RS256**).
* **Scopes answer “what may this client do?”** — After the token is valid, you inspect the `scopes` claim (and your routing rules) to allow or deny the operation.
Skipping either step breaks your security model: verified-but-overpowered clients, or unverified tokens.
## JWKS and Scalekit keys
[Section titled “JWKS and Scalekit keys”](#jwks-and-scalekit-keys)
A **JSON Web Key Set (JWKS)** is JSON that lists one or more **JWKs**—public key material identified by a `kid` (key ID). Scalekit puts the matching `kid` in the JWT header so your validator can pick the right key without baking certificates into your app.
Each environment publishes signing keys at:
```http
1
GET https:///keys
```
Use the same base URL as `/oauth/token` (for example `https://your-app.scalekit.dev`).
Example response shape:
Example JWKS document
```json
1
{
2
"keys": [
3
{
4
"use": "sig",
5
"kty": "RSA",
6
"kid": "snk_58327480989122566",
7
"alg": "RS256",
8
"n": "…",
9
"e": "AQAB"
10
}
11
]
12
}
```
For access tokens, use the key where `use` is `sig` and `alg` is `RS256`.
## Verify an access token
[Section titled “Verify an access token”](#verify-an-access-token)
At implementation time, your API typically:
1. **Extracts** the bearer token from `Authorization: Bearer `.
2. **Decodes** the JWT header (base64url, first segment) and reads `kid` and `alg`. Do not trust the payload until the signature checks out.
3. **Resolves the signing key** — fetch `https:///keys`, or use a JWKS client (for example `jwks-rsa` in Node.js) with **caching** and refresh when you see an unknown `kid`.
4. **Verifies** the signature with your JWT library (RS256 for Scalekit access tokens).
5. **Validates claims** such as `exp`, `iss` (your environment URL), and `aud` if your API relies on audience restrictions.
6. **Authorizes** the operation using application claims—especially **`scopes`** (covered in the next section).
Prefer the Scalekit SDK when possible
SDKs can validate access tokens against JWKS and optionally enforce scopes. See the [M2M API authentication quickstart](/authenticate/m2m/api-auth-quickstart/) and [Authenticate customer apps](/guides/m2m/api-auth-m2m-clients/). Use generic JWT + JWKS libraries when you need custom middleware or an unsupported runtime.
### Operational practices
[Section titled “Operational practices”](#operational-practices)
* **Cache JWKS** responses; refetch when verification fails with an unknown `kid` (key rotation).
* **Fail closed** on bad signature, wrong issuer, or expired token (`401`; use `403` when the token is valid but not allowed).
* **Never** skip signature verification based on the payload alone.
## OAuth scopes for machine clients
[Section titled “OAuth scopes for machine clients”](#oauth-scopes-for-machine-clients)
**Scopes** are permission names you attach to an OAuth client. In M2M flows they describe *what* a client may do—separate from *who* it is (`client_id` / `sub`).
### Why scopes matter
[Section titled “Why scopes matter”](#why-scopes-matter)
Without scopes, any valid client could hit any endpoint. Scopes let you apply **least privilege**, **document** what each integration is for, and **enforce** rules in your API by reading the `scopes` array on the JWT.
### How scopes work in Scalekit M2M
[Section titled “How scopes work in Scalekit M2M”](#how-scopes-work-in-scalekit-m2m)
1. When you **register an API client** for an organization, you pass a `scopes` array (REST or SDKs).
2. Scalekit stores those scopes and includes them on issued access tokens.
3. Your API **verifies the JWT** (steps above), then checks that `scopes` includes what the route requires.
Use a consistent naming pattern such as `resource:action` (for example `deployments:read`, `deployments:write`).
### Register scopes on the client
[Section titled “Register scopes on the client”](#register-scopes-on-the-client)
Scopes are set at **client creation** (and when you update the client via the API). Example:
scopes on create client (illustrative)
```json
1
"scopes": [
2
"deploy:applications",
3
"read:deployments"
4
]
```
The same values appear on the client record and in issued tokens.
Token response vs JWT payload
The `/oauth/token` response may include a space-separated `scope` string for OAuth compatibility. For authorization logic, rely on the JWT payload’s **`scopes` array**. See the [quickstart](/authenticate/m2m/api-auth-quickstart/) for a decoded example.
### Validate scopes on your API
[Section titled “Validate scopes on your API”](#validate-scopes-on-your-api)
After the token is verified:
* **Read `scopes`** from the payload, for example:
scopes in JWT payload (example)
```json
1
"scopes": [
2
"deploy:applications",
3
"read:deployments"
4
]
```
* **Compare** what the token grants to what the route allows (for example require `deploy:applications` on `POST /deploy`); return `403` if a required scope is missing.
* **Use SDK helpers** where they fit your stack to combine signature and scope checks (see the [quickstart](/authenticate/m2m/api-auth-quickstart/)).
## Related
[Section titled “Related”](#related)
* [Add OAuth 2.0 to your APIs](/authenticate/m2m/api-auth-quickstart/) — client registration, tokens, examples
* [API keys](/authenticate/m2m/api-keys/) — long-lived keys; patterns may differ from OAuth client credentials
* [Authenticate customer apps](/guides/m2m/api-auth-m2m-clients/) — customer-facing API auth and JWKS examples
---
# DOCUMENT BOUNDARY
---
# Migrate from Auth0 to Scalekit
> Move users, organizations, and enterprise SSO off Auth0 to Scalekit Full Stack Auth with a safe, incremental cutover.
Migrating a B2B app off Auth0 is risky because three things move at once: user records, the organization or tenant structure, and enterprise SSO connections. Do it in one big switch and you risk locking customers out. This recipe moves each piece to [Scalekit Full Stack Auth](/authenticate/fsa/quickstart/) in a safe, reversible order, then cuts traffic over behind a feature flag.
The approach avoids re-hashing passwords. Instead of copying credentials, you point your app at Scalekit’s hosted login and let users re-authenticate through SSO, social login, or passwordless on their next visit. This is the recommended path for B2B products, where most enterprise users already sign in through an identity provider rather than a password.
Prefer a guided cutover?
The Scalekit Solutions team has run dozens of migrations. For password-hash migration or a staged cut-over plan, [contact us](/support/contact-us) and we’ll design the rollout with you.
## What you build
[Section titled “What you build”](#what-you-build)
* A field mapping from Auth0 users, organizations, and connections to Scalekit
* A one-time import script that recreates organizations and users with `external_id` back-references
* Enterprise SSO connections rebuilt in Scalekit for each customer that used them
* An incremental cutover behind a feature flag, with a rollback path
## Who needs this
[Section titled “Who needs this”](#who-needs-this)
This recipe is for you if:
* You authenticate a B2B or multi-tenant app on Auth0 today, using Auth0 Organizations or per-tenant connections.
* You want Scalekit to own hosted login, sessions, enterprise SSO, and SCIM going forward.
* You can run a short backfill script and toggle a feature flag in your app.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* An Auth0 tenant with a Machine-to-Machine application authorized for the Auth0 Management API.
* A Scalekit account with API credentials from the dashboard. See [Set up Scalekit](/authenticate/fsa/quickstart/).
* The Scalekit SDK installed in your backend.
## How Auth0 concepts map to Scalekit
[Section titled “How Auth0 concepts map to Scalekit”](#how-auth0-concepts-map-to-scalekit)
Start from the data model. Every later step follows this mapping.
| Auth0 concept | Scalekit concept | Notes |
| ------------------------------------ | -------------------- | -------------------------------------------------------------------------------------- |
| Organization | Organization | Store the Auth0 `org_id` as the Scalekit `external_id`. |
| User | User + membership | Users belong to an organization through a membership that carries roles. |
| `user_id` (for example `auth0\|abc`) | User `external_id` | Preserves lookups between systems during cutover. |
| Enterprise connection (SAML, OIDC) | SSO connection | Recreated per organization in Scalekit; secrets are not exportable from Auth0. |
| Roles and permissions | Roles | Recreate roles in Scalekit, then attach them to memberships on import. |
| Database (password) users | Hosted login re-auth | Users re-verify through SSO, social, or passwordless. See the following password note. |
## Migrate the data
[Section titled “Migrate the data”](#migrate-the-data)
1. ## Export your Auth0 data
[Section titled “Export your Auth0 data”](#export-your-auth0-data)
Pull three datasets from Auth0 using the [Management API](https://auth0.com/docs/api/management/v2) or the [User Import / Export extension](https://auth0.com/docs/customize/extensions/user-import-export-extension).
Create a [bulk user export job](https://auth0.com/docs/manage-users/user-migration/bulk-user-exports) to get every user as newline-delimited JSON:
Export Auth0 users
```bash
1
# Security: pass the Management API token from an environment variable, never inline it.
2
curl "https://YOUR_AUTH0_DOMAIN/api/v2/jobs/users-exports" \
3
--request POST \
4
--header "Authorization: Bearer $AUTH0_MGMT_TOKEN" \
5
--header 'Content-Type: application/json' \
6
--data '{
7
"format": "json",
8
"fields": [
9
{ "name": "user_id" },
10
{ "name": "email" },
11
{ "name": "email_verified" },
12
{ "name": "given_name" },
13
{ "name": "family_name" }
14
]
15
}'
```
Poll `GET /api/v2/jobs/{job_id}` until the job reports `completed`, then download the file it returns.
Then export [organizations](https://auth0.com/docs/manage-users/organizations) and their members:
Export Auth0 organizations and members
```bash
1
curl "https://YOUR_AUTH0_DOMAIN/api/v2/organizations" \
2
--header "Authorization: Bearer $AUTH0_MGMT_TOKEN"
3
4
# For each organization id returned above:
5
curl "https://YOUR_AUTH0_DOMAIN/api/v2/organizations/{org_id}/members" \
6
--header "Authorization: Bearer $AUTH0_MGMT_TOKEN"
```
Finally, list the enterprise connections you rebuild in Scalekit. Record the identity provider, metadata URL, and which organizations use each one. Connection secrets stay in the identity provider and are not exportable.
List Auth0 enterprise connections
```bash
1
curl "https://YOUR_AUTH0_DOMAIN/api/v2/connections?strategy=samlp" \
2
--header "Authorization: Bearer $AUTH0_MGMT_TOKEN"
```
2. ## Install the Scalekit SDK
[Section titled “Install the Scalekit SDK”](#install-the-scalekit-sdk)
Add the SDK to the backend that runs your import script.
* Node.js
```bash
npm install @scalekit-sdk/node
```
* Python
```sh
pip install scalekit-sdk-python
```
* Go
```sh
go get -u github.com/scalekit-inc/scalekit-sdk-go
```
* Java
```groovy
/* Gradle users - add the following to your dependencies in build file */
implementation "com.scalekit:scalekit-sdk-java:2.1.3"
```
```xml
com.scalekit
scalekit-sdk-java
2.1.3
```
3. ## Import organizations first
[Section titled “Import organizations first”](#import-organizations-first)
Create each Auth0 organization in Scalekit and set `external_id` to the Auth0 `org_id`. This back-reference lets you attach users to the right organization and reconcile records during cutover.
* Node.js
import-organizations.js
```javascript
1
// organizations: rows read from your Auth0 organizations export
2
for (const org of organizations) {
3
const result = await scalekit.organization.createOrganization(org.display_name, {
4
externalId: org.id, // Auth0 org_id, preserved for lookups
5
metadata: { source: 'auth0' },
6
});
7
console.log(`Created organization: ${result.id}`);
8
}
```
* Python
import\_organizations.py
```python
1
from scalekit.v1.organizations.organizations_pb2 import CreateOrganization
2
3
# organizations: rows read from your Auth0 organizations export
4
for org in organizations:
5
result = scalekit_client.organization.create_organization(
6
CreateOrganization(
7
display_name=org["display_name"],
8
external_id=org["id"], # Auth0 org_id, preserved for lookups
9
metadata={"source": "auth0"},
10
)
11
)
12
print(f"Created organization: {result.id}")
```
* Go
import\_organizations.go
```go
1
// organizations: rows read from your Auth0 organizations export
2
for _, org := range organizations {
3
result, err := scalekitClient.Organization.CreateOrganization(
4
ctx,
5
org.DisplayName,
6
scalekit.CreateOrganizationOptions{
7
ExternalID: org.ID, // Auth0 org_id, preserved for lookups
8
Metadata: map[string]interface{}{"source": "auth0"},
9
},
10
)
11
if err != nil {
12
log.Fatal(err)
13
}
14
fmt.Printf("Created organization: %s\n", result.ID)
15
}
```
* Java
ImportOrganizations.java
```java
1
// organizations: rows read from your Auth0 organizations export
2
for (Map org : organizations) {
3
CreateOrganization createOrganization = CreateOrganization.newBuilder()
4
.setDisplayName((String) org.get("display_name"))
5
.setExternalId((String) org.get("id")) // Auth0 org_id, preserved for lookups
6
.putMetadata("source", "auth0")
7
.build();
8
9
Organization result = scalekitClient.organizations().create(createOrganization);
10
System.out.println("Created organization: " + result.getId());
11
}
```
4. ## Import users into their organizations
[Section titled “Import users into their organizations”](#import-users-into-their-organizations)
Create each user inside the organization it belongs to, and set the user `external_id` to the Auth0 `user_id`. Attach roles through the membership so access control works on the first login.
Set `sendInvitationEmail` to `false` to skip invite emails during a bulk backfill. Scalekit marks the membership `active` and treats the email as verified.
* Node.js
import-users.js
```javascript
1
const { user } = await scalekit.user.createUserAndMembership(organizationId, {
2
email: row.email,
3
externalId: row.user_id, // Auth0 user_id, e.g. "auth0|abc123"
4
sendInvitationEmail: false,
5
userProfile: {
6
firstName: row.given_name,
7
lastName: row.family_name,
8
},
9
metadata: { roles: row.roles?.join(',') ?? '' },
10
});
11
console.log(`Created user: ${user.id}`);
```
* Python
import\_users.py
```python
1
from scalekit.v1.users.users_pb2 import CreateUser
2
from scalekit.v1.commons.commons_pb2 import UserProfile
3
4
user_msg = CreateUser(
5
email=row["email"],
6
external_id=row["user_id"], # Auth0 user_id, e.g. "auth0|abc123"
7
user_profile=UserProfile(
8
first_name=row["given_name"],
9
last_name=row["family_name"],
10
),
11
)
12
13
create_resp, _ = scalekit_client.user.create_user_and_membership(
14
organization_id, user_msg
15
)
16
print(f"Created user: {create_resp.user.id}")
```
* Go
import\_users.go
```go
1
newUser := &usersv1.CreateUser{
2
Email: row.Email,
3
ExternalId: row.UserID, // Auth0 user_id, e.g. "auth0|abc123"
4
UserProfile: &usersv1.CreateUserProfile{
5
FirstName: row.GivenName,
6
LastName: row.FamilyName,
7
},
8
}
9
10
cuResp, err := scalekitClient.User().CreateUserAndMembership(ctx, organizationID, newUser, false)
11
if err != nil {
12
log.Fatal(err)
13
}
14
fmt.Printf("Created user: %s\n", cuResp.User.Id)
```
* Java
ImportUsers.java
```java
1
CreateUser createUser = CreateUser.newBuilder()
2
.setEmail(row.email)
3
.setExternalId(row.userId) // Auth0 user_id, e.g. "auth0|abc123"
4
.setUserProfile(
5
CreateUserProfile.newBuilder()
6
.setFirstName(row.givenName)
7
.setLastName(row.familyName)
8
.build())
9
.build();
10
11
CreateUserAndMembershipResponse cuResp = scalekitClient.users()
12
.createUserAndMembership(organizationId, createUser);
13
System.out.println("Created user: " + cuResp.getUser().getId());
```
Batch the import and run requests in parallel for speed, but respect rate limits. Roles referenced on the membership must exist first. Create them under **User Management > Roles** or with the SDK. See [Create roles and permissions](/authenticate/authz/create-roles-permissions/).
5. ## Rebuild enterprise SSO connections
[Section titled “Rebuild enterprise SSO connections”](#rebuild-enterprise-sso-connections)
For every customer that signed in through an Auth0 enterprise connection, recreate the connection in Scalekit against the same identity provider. You configure this per organization, so each customer keeps its own SAML or OIDC setup.
Follow [Add modular SSO](/authenticate/sso/add-modular-sso/) for each organization. Reuse the identity provider metadata you recorded during export, then re-run the identity provider’s setup to issue fresh SAML or OIDC credentials to Scalekit. Connection secrets from Auth0 cannot be reused.
6. ## Point your app at Scalekit hosted login
[Section titled “Point your app at Scalekit hosted login”](#point-your-app-at-scalekit-hosted-login)
Replace the Auth0 login redirect and session validation with Scalekit.
* Register your callback and post-logout URLs under **Settings > Redirects**. See the [redirect URI guide](/guides/dashboard/redirects/).
* Swap Auth0 SDK session middleware for the Scalekit SDK, or validate access tokens against Scalekit’s JWKS endpoint.
* Read authorization from the `roles` claim that Scalekit issues, in place of Auth0 roles or scopes.
7. ## Cut over incrementally and verify
[Section titled “Cut over incrementally and verify”](#cut-over-incrementally-and-verify)
Roll out behind a feature flag so you can reverse the switch without a redeploy.
1. Route 5 to 10 percent of traffic to Scalekit login and confirm those users authenticate, receive sessions, and see the right roles.
2. Watch authentication success rates and error logs. Verify SSO connections resolve for enterprise organizations.
3. Increase the percentage in stages until all traffic uses Scalekit.
4. Keep the Auth0 tenant read-only until you’re confident, so rollback stays available.
## Handle password-based users
[Section titled “Handle password-based users”](#handle-password-based-users)
Auth0 database users authenticate with a password hash that stays inside Auth0 and can’t be exported. Two paths keep those users signed in:
* **Re-authentication (recommended).** On first visit after cutover, users sign in through SSO, social login, or [passwordless](/authenticate/auth-methods/passwordless/). No password moves, and the `external_id` mapping links them back to their imported record.
* **Password-hash migration.** If you must carry password hashes over, the Scalekit Solutions team handles this directly. [Contact us](/support/contact-us) before you start the import.
## Common mistakes
[Section titled “Common mistakes”](#common-mistakes)
Users import but can’t sign in after cutover
Confirm your callback URL is registered under **Settings > Redirects**, and that the email on the Auth0 record matches the Scalekit record exactly. Mismatched or missing `external_id` values also break reconciliation during a staged rollout.
Roles don’t apply on first login
Roles referenced on a membership must exist in Scalekit before import. Create them first, then re-run the affected user rows. Read access control from the `roles` claim, not from Auth0 scopes.
Enterprise SSO fails for one customer
Each organization needs its own SSO connection. Verify the connection is enabled for that organization and that the identity provider metadata matches what you recorded during export. Test with identity-provider-initiated login.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Migrate to Full Stack Auth](/fsa/guides/migration-guide/): the vendor-neutral migration reference this recipe builds on.
* [Add modular SSO](/authenticate/sso/add-modular-sso/): rebuild each enterprise connection in Scalekit.
* [Create roles and permissions](/authenticate/authz/create-roles-permissions/): set up the roles your imported memberships reference.
STYLE-CHECK: PASSED
---
# DOCUMENT BOUNDARY
---
# Enforce seat limits with SCIM provisioning
> Block over-quota user creation and alert admins when SCIM pushes users beyond your plan seat limit.
SCIM (System for Cross-domain Identity Management) provisioning runs unsupervised. When a customer’s HR system pushes user #51 to a 50-seat plan, your application will create that user unless you explicitly block it. Scalekit delivers the provisioning events; your application decides whether to act on them.
This cookbook shows the two-event pattern that keeps your seat count accurate and tells admins when they need to upgrade their plan.
Full Stack Auth handles this automatically
This pattern applies to **Modular SCIM** customers who manage their own user database. If you use Scalekit Full Stack Auth, seat enforcement is built in — you don’t need this cookbook.
## SCIM does not enforce seat limits — your app must
[Section titled “SCIM does not enforce seat limits — your app must”](#scim-does-not-enforce-seat-limits--your-app-must)
Scalekit translates IdP-specific provisioning protocols into a consistent set of webhook events. It does not know your billing model, your seat limits, or which organizations have room for more users. That logic lives in your application.
When a user is added in the IdP, Scalekit fires `organization.directory.user_created`. When a user is removed or deactivated, Scalekit fires `organization.directory.user_deleted`. Your webhook handler is the gate between those events and your user table.
## Two webhook events carry the full user lifecycle
[Section titled “Two webhook events carry the full user lifecycle”](#two-webhook-events-carry-the-full-user-lifecycle)
Both events include the `organization_id`, which lets you look up the seat limit for that specific customer.
| Event | When it fires | What to do |
| ------------------------------------- | --------------------------------- | ----------------------------------------------------- |
| `organization.directory.user_created` | IdP adds or activates a user | Check count — create user or block and notify |
| `organization.directory.user_deleted` | IdP removes or deactivates a user | Decrement count — clear any blocked-provisioning flag |
## Track a user count per organization in your database
[Section titled “Track a user count per organization in your database”](#track-a-user-count-per-organization-in-your-database)
Add a table that stores the provisioned user count and seat limit for each organization. The examples below use plain SQL — translate to your ORM if preferred.
db/schema.sql
```sql
1
CREATE TABLE org_seat_usage (
2
org_id TEXT PRIMARY KEY,
3
seat_limit INTEGER NOT NULL,
4
used_seats INTEGER NOT NULL DEFAULT 0
5
);
```
Seed this table when you onboard a new customer. Update `seat_limit` whenever the customer upgrades or downgrades their plan.
## Block creation when the count reaches the limit
[Section titled “Block creation when the count reaches the limit”](#block-creation-when-the-count-reaches-the-limit)
The `user_created` handler increments the seat counter and creates the user only when there is room. Always return `200` to Scalekit — returning an error code causes Scalekit to retry delivery, which does not help when the block is intentional.
Verify webhook signatures before processing
Always verify that events come from Scalekit before acting on them. An unverified endpoint that mutates your database can be triggered by forged requests. See the [SCIM provisioning quickstart](/directory/scim/quickstart/) for how to verify signatures using the Scalekit SDK.
Keep the lock inside the transaction
The `SELECT ... FOR UPDATE` must run inside the same explicit transaction as the `INSERT` and `UPDATE`. In autocommit mode, a `FOR UPDATE` outside a transaction is released immediately after the select — it provides no protection against concurrent writes.
* Node.js
webhook-handler.ts
```ts
1
import express from 'express'
2
3
const app = express()
4
app.use(express.json())
5
6
app.post('/webhooks/scalekit', async (req, res) => {
7
const event = req.body
8
9
if (event.type === 'organization.directory.user_created') {
10
const orgId = event.organization_id
11
const directoryUser = event.data
12
let seatLimitReached = false
13
14
// Run the check and insert in a single transaction.
15
// FOR UPDATE inside the transaction holds the lock until commit.
16
await db.transaction(async (tx) => {
17
const usage = await tx.queryOne(
18
'SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = $1 FOR UPDATE',
19
[orgId]
20
)
21
22
if (!usage || usage.used_seats >= usage.seat_limit) {
23
seatLimitReached = true
24
return
25
}
26
27
await tx.query(
28
'INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, $4)',
29
[directoryUser.id, orgId, directoryUser.email, directoryUser.name]
30
)
31
await tx.query(
32
'UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = $1',
33
[orgId]
34
)
35
})
36
37
if (seatLimitReached) {
38
// Seat limit reached — skip user creation and alert the admin.
39
await notifyAdminSeatLimitReached(orgId)
40
}
41
}
42
43
// Return 200 so Scalekit does not retry this event.
44
res.sendStatus(200)
45
})
```
* Python
webhook\_handler.py
```python
1
from flask import Flask, request
2
3
app = Flask(__name__)
4
5
@app.route('/webhooks/scalekit', methods=['POST'])
6
def handle_webhook():
7
event = request.get_json()
8
9
if event.get('type') == 'organization.directory.user_created':
10
org_id = event['organization_id']
11
directory_user = event['data']
12
seat_limit_reached = False
13
14
# Run the check and insert in a single transaction.
15
# FOR UPDATE inside the transaction holds the lock until commit.
16
with db.transaction() as tx:
17
usage = tx.query_one(
18
'SELECT seat_limit, used_seats FROM org_seat_usage '
19
'WHERE org_id = %s FOR UPDATE',
20
(org_id,)
21
)
22
23
if not usage or usage['used_seats'] >= usage['seat_limit']:
24
seat_limit_reached = True
25
else:
26
tx.execute(
27
'INSERT INTO users (id, org_id, email, name) VALUES (%s, %s, %s, %s)',
28
(directory_user['id'], org_id,
29
directory_user['email'], directory_user['name'])
30
)
31
tx.execute(
32
'UPDATE org_seat_usage SET used_seats = used_seats + 1 '
33
'WHERE org_id = %s',
34
(org_id,)
35
)
36
37
if seat_limit_reached:
38
# Seat limit reached — skip user creation and alert the admin.
39
notify_admin_seat_limit_reached(org_id)
40
41
# Return 200 so Scalekit does not retry this event.
42
return '', 200
```
* Go
webhook\_handler.go
```go
1
package main
2
3
import (
4
"encoding/json"
5
"net/http"
6
)
7
8
func webhookHandler(w http.ResponseWriter, r *http.Request) {
9
var event map[string]interface{}
10
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
11
http.Error(w, "bad request", http.StatusBadRequest)
12
return
13
}
14
15
if event["type"] == "organization.directory.user_created" {
16
orgID := event["organization_id"].(string)
17
data := event["data"].(map[string]interface{})
18
seatLimitReached := false
19
20
// Run the check and insert in a single transaction.
21
// FOR UPDATE inside the transaction holds the lock until commit.
22
tx, _ := db.Begin()
23
var seatLimit, usedSeats int
24
err := tx.QueryRow(
25
"SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = $1 FOR UPDATE",
26
orgID,
27
).Scan(&seatLimit, &usedSeats)
28
29
if err != nil || usedSeats >= seatLimit {
30
seatLimitReached = true
31
tx.Rollback()
32
} else {
33
tx.Exec(
34
"INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, $4)",
35
data["id"], orgID, data["email"], data["name"],
36
)
37
tx.Exec(
38
"UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = $1",
39
orgID,
40
)
41
tx.Commit()
42
}
43
44
if seatLimitReached {
45
// Seat limit reached — skip user creation and alert the admin.
46
notifyAdminSeatLimitReached(orgID)
47
}
48
}
49
50
// Return 200 so Scalekit does not retry this event.
51
w.WriteHeader(http.StatusOK)
52
}
```
* Java
WebhookController.java
```java
1
import org.springframework.web.bind.annotation.*;
2
import java.util.Map;
3
import java.util.concurrent.atomic.AtomicBoolean;
4
5
@RestController
6
public class WebhookController {
7
8
@PostMapping("/webhooks/scalekit")
9
public ResponseEntity handleWebhook(@RequestBody Map event) {
10
if ("organization.directory.user_created".equals(event.get("type"))) {
11
String orgId = (String) event.get("organization_id");
12
Map directoryUser = (Map) event.get("data");
13
AtomicBoolean seatLimitReached = new AtomicBoolean(false);
14
15
// Run the check and insert in a single transaction.
16
// FOR UPDATE inside the transaction holds the lock until commit.
17
transactionTemplate.execute(status -> {
18
OrgSeatUsage usage = db.queryForObject(
19
"SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = ? FOR UPDATE",
20
OrgSeatUsage.class, orgId
21
);
22
23
if (usage == null || usage.getUsedSeats() >= usage.getSeatLimit()) {
24
seatLimitReached.set(true);
25
return null;
26
}
27
28
db.update(
29
"INSERT INTO users (id, org_id, email, name) VALUES (?, ?, ?, ?)",
30
directoryUser.get("id"), orgId,
31
directoryUser.get("email"), directoryUser.get("name")
32
);
33
db.update(
34
"UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = ?",
35
orgId
36
);
37
return null;
38
});
39
40
if (seatLimitReached.get()) {
41
// Seat limit reached — skip user creation and alert the admin.
42
notifyAdminSeatLimitReached(orgId);
43
}
44
}
45
46
// Return 200 so Scalekit does not retry this event.
47
return ResponseEntity.ok().build();
48
}
49
}
```
## Decrement the count when a user is removed
[Section titled “Decrement the count when a user is removed”](#decrement-the-count-when-a-user-is-removed)
The `user_deleted` handler decreases the seat counter and clears any pending seat-limit notification. This lets the next `user_created` event succeed without manual intervention from your team.
Webhook events are delivered at least once
Scalekit may deliver the same `user_deleted` event more than once. The `GREATEST(used_seats - 1, 0)` guard prevents the counter from going below zero, but it does not prevent double-decrements on duplicate events. For high-reliability systems, track processed event IDs using `event.id` from the webhook payload and skip events you have already handled.
* Node.js
webhook-handler.ts
```ts
1
if (event.type === 'organization.directory.user_deleted') {
2
const orgId = event.organization_id
3
const directoryUser = event.data
4
5
await db.transaction(async (tx) => {
6
// Remove the user and decrement the counter atomically.
7
await tx.query('DELETE FROM users WHERE id = $1', [directoryUser.id])
8
await tx.query(
9
'UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) WHERE org_id = $1',
10
[orgId]
11
)
12
// Clear any pending seat-limit notification so the next user can be provisioned.
13
await tx.query(
14
"DELETE FROM notifications WHERE org_id = $1 AND type = 'seat_limit_reached'",
15
[orgId]
16
)
17
})
18
}
```
* Python
webhook\_handler.py
```python
1
if event.get('type') == 'organization.directory.user_deleted':
2
org_id = event['organization_id']
3
directory_user = event['data']
4
5
with db.transaction() as tx:
6
# Remove the user and decrement the counter atomically.
7
tx.execute('DELETE FROM users WHERE id = %s', (directory_user['id'],))
8
tx.execute(
9
'UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) '
10
'WHERE org_id = %s',
11
(org_id,)
12
)
13
# Clear any pending seat-limit notification so the next user can be provisioned.
14
tx.execute(
15
"DELETE FROM notifications WHERE org_id = %s AND type = 'seat_limit_reached'",
16
(org_id,)
17
)
```
* Go
webhook\_handler.go
```go
1
if event["type"] == "organization.directory.user_deleted" {
2
orgID := event["organization_id"].(string)
3
data := event["data"].(map[string]interface{})
4
5
tx, _ := db.Begin()
6
// Remove the user and decrement the counter atomically.
7
tx.Exec("DELETE FROM users WHERE id = $1", data["id"])
8
tx.Exec(
9
"UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) WHERE org_id = $1",
10
orgID,
11
)
12
// Clear any pending seat-limit notification so the next user can be provisioned.
13
tx.Exec(
14
"DELETE FROM notifications WHERE org_id = $1 AND type = 'seat_limit_reached'",
15
orgID,
16
)
17
tx.Commit()
18
}
```
* Java
WebhookController.java
```java
1
if ("organization.directory.user_deleted".equals(event.get("type"))) {
2
String orgId = (String) event.get("organization_id");
3
Map directoryUser = (Map) event.get("data");
4
5
transactionTemplate.execute(status -> {
6
// Remove the user and decrement the counter atomically.
7
db.update("DELETE FROM users WHERE id = ?", directoryUser.get("id"));
8
db.update(
9
"UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) WHERE org_id = ?",
10
orgId
11
);
12
// Clear any pending seat-limit notification so the next user can be provisioned.
13
db.update(
14
"DELETE FROM notifications WHERE org_id = ? AND type = 'seat_limit_reached'",
15
orgId
16
);
17
return null;
18
});
19
}
```
## Notify admins without spamming them
[Section titled “Notify admins without spamming them”](#notify-admins-without-spamming-them)
A new `user_created` event fires for every blocked user. Without deduplication, your admin will receive one email per rejected provisioning attempt. Use an idempotent insert to fire the notification only once per organization until the condition is resolved.
db/schema.sql
```sql
1
CREATE TABLE notifications (
2
id SERIAL PRIMARY KEY,
3
org_id TEXT NOT NULL,
4
type TEXT NOT NULL,
5
resolved BOOLEAN NOT NULL DEFAULT FALSE,
6
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
UNIQUE (org_id, type, resolved)
8
);
```
The `UNIQUE (org_id, type, resolved)` constraint blocks duplicate active notifications. Insert with `ON CONFLICT DO NOTHING` to skip the insert when a notification already exists:
* Node.js
notify.ts
```ts
1
async function notifyAdminSeatLimitReached(orgId: string) {
2
// Insert only if no unresolved notification exists for this org.
3
const result = await db.query(
4
`INSERT INTO notifications (org_id, type, resolved)
5
VALUES ($1, 'seat_limit_reached', FALSE)
6
ON CONFLICT (org_id, type, resolved) DO NOTHING`,
7
[orgId]
8
)
9
10
// rowCount is 0 when the conflict was skipped — admin already notified.
11
if (result.rowCount === 0) return
12
13
// Send the alert once: email, Slack, in-app — your choice.
14
await sendAdminAlert(orgId, 'Seat limit reached — users are not being provisioned.')
15
}
```
* Python
notify.py
```python
1
def notify_admin_seat_limit_reached(org_id: str) -> None:
2
# Insert only if no unresolved notification exists for this org.
3
result = db.execute(
4
"""INSERT INTO notifications (org_id, type, resolved)
5
VALUES (%s, 'seat_limit_reached', FALSE)
6
ON CONFLICT (org_id, type, resolved) DO NOTHING""",
7
(org_id,)
8
)
9
10
# rowcount is 0 when the conflict was skipped — admin already notified.
11
if result.rowcount == 0:
12
return
13
14
# Send the alert once: email, Slack, in-app — your choice.
15
send_admin_alert(org_id, 'Seat limit reached — users are not being provisioned.')
```
* Go
notify.go
```go
1
func notifyAdminSeatLimitReached(orgID string) {
2
// Insert only if no unresolved notification exists for this org.
3
result, _ := db.Exec(
4
`INSERT INTO notifications (org_id, type, resolved)
5
VALUES ($1, 'seat_limit_reached', FALSE)
6
ON CONFLICT (org_id, type, resolved) DO NOTHING`,
7
orgID,
8
)
9
10
// RowsAffected is 0 when the conflict was skipped — admin already notified.
11
rows, _ := result.RowsAffected()
12
if rows == 0 {
13
return
14
}
15
16
// Send the alert once: email, Slack, in-app — your choice.
17
sendAdminAlert(orgID, "Seat limit reached — users are not being provisioned.")
18
}
```
* Java
NotificationService.java
```java
1
public void notifyAdminSeatLimitReached(String orgId) {
2
// Insert only if no unresolved notification exists for this org.
3
int rows = db.update(
4
"INSERT INTO notifications (org_id, type, resolved) " +
5
"VALUES (?, 'seat_limit_reached', FALSE) " +
6
"ON CONFLICT (org_id, type, resolved) DO NOTHING",
7
orgId
8
);
9
10
// rows is 0 when the conflict was skipped — admin already notified.
11
if (rows == 0) return;
12
13
// Send the alert once: email, Slack, in-app — your choice.
14
sendAdminAlert(orgId, "Seat limit reached — users are not being provisioned.");
15
}
```
When a user is removed and the count drops below the limit, the `user_deleted` handler deletes the notification row. The next blocked `user_created` event will insert a fresh notification and trigger a new alert.
***
**Related guides**
* [SCIM provisioning quickstart](/directory/scim/quickstart/) — set up webhooks and the Directory API, including signature verification
* [Directory webhook events reference](/directory/reference/directory-events/) — full event payload schemas
---
# DOCUMENT BOUNDARY
---
# Search Scalekit docs with ref.tools
> Configure ref.tools MCP to search Scalekit documentation directly from Cursor, Claude Code, or Windsurf without leaving your IDE.
Every time you need to look up a Scalekit API, scope name, or configuration option, you break your flow: open a new tab, search the docs, copy the answer, switch back. With ref.tools configured as an MCP server, your AI coding assistant can search Scalekit documentation inline and return accurate, up-to-date answers without you leaving the editor. Setup takes about two minutes.
## The problem
[Section titled “The problem”](#the-problem)
AI coding assistants are good at generating code, but they have two failure modes when it comes to third-party docs:
* **Hallucination** — The model invents an API that doesn’t exist or gets parameter names wrong because its training data is incomplete
* **Stale knowledge** — Even accurate training data goes out of date as SDKs and APIs evolve
Both problems get worse when you’re working with a narrowly scoped platform like Scalekit. The model may have seen very little training data about it, and what it did see may be outdated.
The standard workaround is to paste docs into the chat manually — which means constant context-switching between your editor and a browser. ref.tools solves both problems by connecting your AI assistant directly to live Scalekit documentation through an MCP tool call.
## Who needs this
[Section titled “Who needs this”](#who-needs-this)
This cookbook is for you if:
* ✅ You use Cursor, Claude Code, Windsurf, or another MCP-compatible AI assistant
* ✅ You’re building with Scalekit (auth, SSO, MCP servers, M2M, SCIM)
* ✅ You want accurate, up-to-date answers without context-switching to a browser
You **don’t** need this if:
* ❌ You prefer pasting docs into your chat manually
* ❌ Your AI assistant doesn’t support MCP
## The solution
[Section titled “The solution”](#the-solution)
[ref.tools](https://ref.tools) is a documentation search platform that indexes third-party docs — including Scalekit — and exposes them as an MCP tool called `ref_search_documentation`. Once you add the ref.tools MCP server to your AI assistant, you can prompt it to search Scalekit docs and it will call the tool and return current results directly in chat.
The server supports two transports:
* **Streamable HTTP** (recommended) — Direct HTTP connection using your API key; lower latency, no local process required
* **stdio** (legacy) — Runs a local `npx` process; works with any MCP client that supports stdio
## Set up ref.tools
[Section titled “Set up ref.tools”](#set-up-reftools)
1. ### Get your API key
[Section titled “Get your API key”](#get-your-api-key)
1. Go to [ref.tools](https://ref.tools) and sign in
2. Search for **Scalekit** to confirm the documentation source is indexed
3. Open the **Quick Install** panel for Scalekit — your API key is pre-filled in the install commands
4. Copy your API key; you’ll use it in the next step
2. ### Add the MCP server to your AI assistant
[Section titled “Add the MCP server to your AI assistant”](#add-the-mcp-server-to-your-ai-assistant)
Pick your tool and apply the matching configuration.
#### Claude Code
[Section titled “Claude Code”](#claude-code)
Run this command in your terminal to add the MCP server globally across all projects:
```bash
1
claude mcp add --transport http ref-context https://api.ref.tools/mcp \
2
--header "x-ref-api-key: YOUR_API_KEY"
```
To scope it to a single project instead, add `--scope project` to the command.
#### Cursor
[Section titled “Cursor”](#cursor)
Add the following to `.cursor/mcp.json` in your project root (or via **Settings → MCP**):
.cursor/mcp.json
```json
1
{
2
"ref-context": {
3
"type": "http",
4
"url": "https://api.ref.tools/mcp?apiKey=YOUR_API_KEY"
5
}
6
}
```
#### Windsurf
[Section titled “Windsurf”](#windsurf)
Add the following to `~/.codeium/windsurf/mcp_config.json`:
\~/.codeium/windsurf/mcp\_config.json
```json
1
{
2
"ref-context": {
3
"serverUrl": "https://api.ref.tools/mcp?apiKey=YOUR_API_KEY"
4
}
5
}
```
#### Other (stdio)
[Section titled “Other (stdio)”](#other-stdio)
For any MCP client that supports stdio, add to your MCP config:
mcp.json
```json
1
{
2
"ref-context": {
3
"command": "npx",
4
"args": ["ref-tools-mcp@latest"],
5
"env": {
6
"REF_API_KEY": "YOUR_API_KEY"
7
}
8
}
9
}
```
This requires Node.js installed locally. The `npx` command fetches and runs the server on first use.
3. ### Verify it’s working
[Section titled “Verify it’s working”](#verify-its-working)
1. Restart your AI assistant (or use its MCP reload command if available)
2. Open a new chat and send this prompt:
```plaintext
1
Use ref to look up how to add OAuth 2.1 authorization to an MCP server with Scalekit
```
3. Your assistant should call the `ref_search_documentation` tool and return results from `docs.scalekit.com`
If the tool doesn’t appear, check that you restarted the assistant after saving the config, and that the API key is correct.
Keep your API key private
Never commit your ref.tools API key to source control. For project-level configs checked into git, pass the key through an environment variable and reference it as `$REF_API_KEY` in your config, or add the config file to `.gitignore`.
## Example searches to try
[Section titled “Example searches to try”](#example-searches-to-try)
Once ref.tools is connected, use phrases like “use ref to…” or “look up in ref…” to trigger the tool explicitly:
* `Use ref to find the Scalekit MCP auth quickstart`
* `Look up how to configure SSO with Scalekit`
* `Use ref to find Scalekit M2M token documentation`
* `Search Scalekit docs for SCIM provisioning setup`
* `Use ref to look up Scalekit SDK environment variables`
You can also just ask naturally — most assistants will call the tool automatically when the question is about Scalekit.
## Common mistakes
[Section titled “Common mistakes”](#common-mistakes)
API key committed to git
* **Symptom**: Your key appears in git history or a public repository
* **Cause**: Config file with the key inline was committed
* **Fix**: Use an environment variable (`$REF_API_KEY`) and add the config file to `.gitignore` if it contains real credentials
Wrong transport for your client
* **Symptom**: MCP server fails to connect or appears as disconnected
* **Cause**: Some clients only support stdio; others support both HTTP and stdio
* **Fix**: Check your client’s MCP documentation. Cursor and Claude Code support streamable HTTP. Older or less common clients may require stdio.
Server name not matching what the client expects
* **Symptom**: Tool calls fail with “unknown tool” or the server doesn’t appear in the tool list
* **Cause**: The config key (e.g., `ref-context`) doesn’t match what you reference in prompts, or the client uses a different config field name
* **Fix**: Confirm the key in your config file matches the server name shown in your client’s MCP settings panel
Tool not appearing after config change
* **Symptom**: You updated the config but the `ref_search_documentation` tool isn’t available
* **Cause**: The MCP connection wasn’t refreshed
* **Fix**: Fully restart your AI assistant, or use its MCP reload command (Claude Code: `claude mcp list` to verify; Cursor: reload the window)
## Next steps
[Section titled “Next steps”](#next-steps)
For further setup, authentication options, and available documentation sources, see the links below.
* [Add OAuth 2.1 authorization to MCP servers](/authenticate/mcp/quickstart) — the most common thing developers look up using ref
* [ref.tools](https://ref.tools) — browse all available documentation sources you can add alongside Scalekit
* [M2M authentication overview](/guides/m2m/overview) — machine-to-machine auth patterns frequently searched via ref
---
# DOCUMENT BOUNDARY
---
# Sync B2B billing with Scalekit and Chargebee
> Map Scalekit organizations to Chargebee customers, run hosted checkout, and keep subscription state in sync via webhooks.
Multi-tenant B2B SaaS apps authenticate users through Scalekit organizations, but bill through Chargebee subscriptions. Those two systems do not share a database. Without an explicit mapping, you end up with duplicate Chargebee customers, subscriptions that never activate after checkout, or feature gates that read stale plan data.
This cookbook wires Scalekit organizations and sessions to Chargebee using **org-mode billing**: the organization ID from the access token (`oid`) becomes the billing `referenceId`, Scalekit webhooks provision Chargebee customers, and Chargebee webhooks keep your local subscription table current. You own the routes, schema, and authorization that connect the two systems.
Working example repo
Patterns below are implemented end-to-end in [saas-auth-chargebee-example](https://github.com/scalekit-developers/saas-auth-chargebee-example) (Next.js, Scalekit, Chargebee Node SDK, Drizzle + SQLite). Clone it to follow along locally, including the in-app journey at `/guide`.
## What you get
[Section titled “What you get”](#what-you-get)
* Chargebee customers created when Scalekit fires `organization.created` (not on every user signup)
* Local org ↔ Chargebee customer mapping keyed by the Scalekit organization ID
* Hosted checkout and customer portal via Chargebee hosted pages
* Local subscription cache driven by Chargebee webhooks, plus optional eager sync on checkout redirect
* Session-scoped authorization so billing APIs only act on the caller’s org (`referenceId === oid`)
* Lifecycle hooks for product logic (after customer create, subscription complete/cancel, authorize deny)
## Who needs this
[Section titled “Who needs this”](#who-needs-this)
This cookbook is for you if:
* ✅ You authenticate with Scalekit and use **organizations** (`oid` in access tokens)
* ✅ You bill **per organization**, not per individual user
* ✅ You use Chargebee hosted checkout or the customer portal
* ✅ You maintain a local subscription cache to gate features in your app
You **don’t** need this if:
* ❌ You bill per user, not per organization
* ❌ Scalekit manages your entire product catalog and entitlements (no separate billing system)
## How the integration fits together
[Section titled “How the integration fits together”](#how-the-integration-fits-together)
Treat the Scalekit **organization ID** as the single billing reference for the tenant. Scalekit authenticates the user and org, your app owns the mapping and local subscription cache, and Chargebee owns catalog, checkout, and billing state.

The integration has four seams:
1. **Provision on org create** — Scalekit `organization.created` webhook → create a Chargebee customer and store the mapping locally.
2. **Authorize every billing call** — session `oid` must match the billing `referenceId` before any Chargebee API call.
3. **Future subscription before checkout** — create a local row with `status: future`, stamp `pendingSubscriptionId` on Chargebee customer metadata, then redirect to hosted checkout.
4. **Reconcile from Chargebee** — subscription webhooks (and an eager sync on checkout redirect) update the local row to `active`, `in_trial`, or cancelled.
Screenshots below are from the [reference demo](https://github.com/scalekit-developers/saas-auth-chargebee-example) so you can match each milestone to the product UI.
## Before you start
[Section titled “Before you start”](#before-you-start)
| Prerequisite | Where to get it |
| ---------------------------------------------------------- | -------------------------------------------------------------- |
| Scalekit environment with organizations | [Scalekit dashboard](https://app.scalekit.com/) |
| OAuth client (`skc_...`) + redirect URI | **API Keys** in the dashboard |
| Chargebee sandbox site (Product Catalog 2.0) | Chargebee test site |
| Plan **item price** ID (for example `growth-plan-monthly`) | Chargebee **Product Catalog** — reference prices by ID in code |
| Test payment gateway (`gw_...`) | Chargebee **Payment Gateways** |
| Public tunnel for webhooks | ngrok, LocalTunnel, or similar |
## Step 1: Install packages
[Section titled “Step 1: Install packages”](#step-1-install-packages)
Install the Scalekit and Chargebee SDKs on the **server** (API routes, webhook handlers). Use whichever package manager you use in your app (`npm`, `pnpm`, or `yarn`); the example below matches the reference app:
Terminal
```bash
1
npm install @scalekit-sdk/node chargebee
```
Snippets in this cookbook are **Node.js / Next.js App Router**, aligned with the reference app. Scalekit client concepts (token validation, webhook verification, `oid`) apply across SDKs; adapt routes and session storage if you run another stack. Chargebee’s primary SDK surface used here is the Node package.
Use your ORM of choice for the local billing tables (the reference app uses Drizzle + SQLite). Keep Chargebee secret API keys server-side only; publishable keys for Chargebee.js may use `NEXT_PUBLIC_*` if you embed payment components.
## Step 2: Configure environment variables
[Section titled “Step 2: Configure environment variables”](#step-2-configure-environment-variables)
Define these in your server environment (for example `.env` locally and your host’s secrets store in production).
| Variable | Purpose |
| ----------------------------------------------------------- | ------------------------------------------------------------------ |
| `SCALEKIT_ENV_URL` | Scalekit environment URL |
| `SCALEKIT_CLIENT_ID` / `SCALEKIT_CLIENT_SECRET` | OAuth client |
| `SCALEKIT_REDIRECT_URI` | OAuth callback (for example `http://localhost:3000/auth/callback`) |
| `SCALEKIT_WEBHOOK_SECRET` | Verify Scalekit webhook signatures |
| `CHARGEBEE_SITE` | Chargebee site subdomain |
| `CHARGEBEE_API_KEY` | Full-access API key for your Chargebee site |
| `CHARGEBEE_PLAN_ITEM_PRICE_ID` | Default plan item price ID from Product Catalog 2.0 |
| `CHARGEBEE_GATEWAY_ACCOUNT_ID` | Optional gateway pin for hosted checkout (`gw_...`) |
| `CHARGEBEE_WEBHOOK_USERNAME` / `CHARGEBEE_WEBHOOK_PASSWORD` | Basic Auth for Chargebee webhooks (recommended in production) |
| `NEXT_PUBLIC_APP_URL` | App base URL for hosted-page redirects |
.env.example
```bash
1
SCALEKIT_ENV_URL=https://your-env.scalekit.dev
2
SCALEKIT_CLIENT_ID=skc_...
3
SCALEKIT_CLIENT_SECRET=
4
SCALEKIT_REDIRECT_URI=http://localhost:3000/auth/callback
5
SCALEKIT_WEBHOOK_SECRET=
6
CHARGEBEE_SITE=your-site-test
7
CHARGEBEE_API_KEY=
8
CHARGEBEE_PLAN_ITEM_PRICE_ID=growth-plan-monthly
9
CHARGEBEE_GATEWAY_ACCOUNT_ID=gw_your_test_gateway_id
10
CHARGEBEE_WEBHOOK_USERNAME=
11
CHARGEBEE_WEBHOOK_PASSWORD=
12
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
## Step 3: Add local schema
[Section titled “Step 3: Add local schema”](#step-3-add-local-schema)
Add tables for organizations, subscriptions, and optional line items. The organization row holds the Chargebee customer ID; subscriptions are keyed by `reference_id` (the Scalekit org ID from `oid`). Default new subscriptions to `future` so checkout can reconcile before Chargebee assigns a subscription ID.
db/schema.sql
```sql
1
CREATE TABLE organization (
2
id TEXT PRIMARY KEY,
3
display_name TEXT,
4
chargebee_customer_id TEXT UNIQUE,
5
updated_at INTEGER
6
);
7
8
CREATE TABLE subscription (
9
id TEXT PRIMARY KEY,
10
reference_id TEXT NOT NULL,
11
chargebee_customer_id TEXT,
12
chargebee_subscription_id TEXT UNIQUE,
13
status TEXT NOT NULL DEFAULT 'future',
14
period_start INTEGER,
15
period_end INTEGER,
16
trial_start INTEGER,
17
trial_end INTEGER,
18
canceled_at INTEGER,
19
seats INTEGER,
20
metadata TEXT
21
);
22
23
CREATE TABLE subscription_item (
24
id TEXT PRIMARY KEY,
25
subscription_id TEXT NOT NULL REFERENCES subscription(id) ON DELETE CASCADE,
26
item_price_id TEXT NOT NULL,
27
item_type TEXT NOT NULL,
28
quantity INTEGER NOT NULL,
29
unit_price INTEGER,
30
amount INTEGER
31
);
```
Translate to your ORM. Index `reference_id` — webhook handlers and list endpoints query by org ID on every request.
**After this step:** migrations applied; empty tables ready for provisioning and checkout.
## Step 4: Initialize clients
[Section titled “Step 4: Initialize clients”](#step-4-initialize-clients)
Create lazy singletons from environment variables. Never hardcode API keys.
lib/scalekit.ts
```ts
1
import { ScalekitClient } from '@scalekit-sdk/node';
2
3
let scalekitClient: ScalekitClient | null = null;
4
5
export function getScalekitClient(): ScalekitClient {
6
if (!scalekitClient) {
7
const envUrl = process.env.SCALEKIT_ENV_URL;
8
const clientId = process.env.SCALEKIT_CLIENT_ID;
9
const clientSecret = process.env.SCALEKIT_CLIENT_SECRET;
10
if (!envUrl || !clientId || !clientSecret) {
11
throw new Error(
12
'Set SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET.'
13
);
14
}
15
scalekitClient = new ScalekitClient(envUrl, clientId, clientSecret);
16
}
17
return scalekitClient;
18
}
```
lib/chargebee.ts
```ts
1
import Chargebee from 'chargebee';
2
3
let chargebeeClient: Chargebee | null = null;
4
5
export function getChargebeeClient(): Chargebee {
6
if (!chargebeeClient) {
7
const site = process.env.CHARGEBEE_SITE;
8
const apiKey = process.env.CHARGEBEE_API_KEY;
9
if (!site || !apiKey) {
10
throw new Error('Set CHARGEBEE_SITE and CHARGEBEE_API_KEY.');
11
}
12
chargebeeClient = new Chargebee({ site, apiKey });
13
}
14
return chargebeeClient;
15
}
```
## Step 5: Provision Chargebee customers from Scalekit webhooks
[Section titled “Step 5: Provision Chargebee customers from Scalekit webhooks”](#step-5-provision-chargebee-customers-from-scalekit-webhooks)
Register a Scalekit webhook for `organization.created`, `organization.updated`, and `organization.deleted`. Point it at your public URL (use a tunnel in local dev):
```text
1
https://your-domain.com/api/webhooks/scalekit
```
Verify the signature on the **raw request body** before parsing JSON.
Verify webhook signatures
Never parse the body before verification. Re-serialized JSON breaks signature checks. Read `req.text()` (or the raw buffer), verify, then `JSON.parse`.
api/webhooks/scalekit/route.ts
```ts
1
import { NextRequest, NextResponse } from 'next/server';
2
import { getScalekitClient } from '@/lib/scalekit';
3
import { createOrgCustomer } from '@/lib/billing/create-org-customer';
4
import { cleanupOrganizationBilling } from '@/lib/billing/cleanup-org';
5
import { upsertOrganization } from '@/lib/db/organizations';
6
7
export async function POST(req: NextRequest) {
8
const rawBody = await req.text();
9
const secret = process.env.SCALEKIT_WEBHOOK_SECRET;
10
if (!secret) {
11
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
12
}
13
14
const headers: Record = {};
15
req.headers.forEach((value, key) => {
16
headers[key.toLowerCase()] = value;
17
});
18
19
const client = getScalekitClient();
20
const isValid = client.verifyWebhookPayload(secret, headers, rawBody);
21
if (!isValid) {
22
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
23
}
24
25
const event = JSON.parse(rawBody);
26
const organizationId = event.organization_id ?? event.data?.id;
27
28
if (event.type === 'organization.created' && organizationId) {
29
await createOrgCustomer({
30
organizationId,
31
displayName: event.data?.display_name ?? null,
32
});
33
} else if (event.type === 'organization.updated' && organizationId) {
34
await upsertOrganization({
35
id: organizationId,
36
displayName: event.data?.display_name ?? null,
37
});
38
} else if (event.type === 'organization.deleted' && organizationId) {
39
await cleanupOrganizationBilling(organizationId);
40
}
41
42
return NextResponse.json({ received: true });
43
}
```
`createOrgCustomer` upserts the local organization row, creates a Chargebee customer if one does not exist, and stores `organizationId` in Chargebee `meta_data`. Make it idempotent: check the local mapping before calling `customer.create`, and handle races if checkout runs before the webhook finishes.
lib/billing/create-org-customer.ts
```ts
1
const { customer } = await chargebee.customer.create({
2
company: displayName ?? undefined,
3
email: email ?? undefined,
4
preferred_currency_code: 'USD',
5
meta_data: {
6
organizationId,
7
customerType: 'organization',
8
},
9
});
10
11
await setChargebeeCustomerId(organizationId, customer.id);
```
Return `2xx` after accepting the event. Scalekit retries on non-2xx responses. The reference app enqueues work with `setImmediate` so the HTTP response is fast; either pattern works if handlers are idempotent.
**After this step:** create an organization in Scalekit → local `organization` row and a Chargebee customer with matching `organizationId` metadata appear.
In the reference app dashboard, steps 1–3 show **Done** and the org is linked to a Chargebee customer before you open billing:

## Step 6: Read the organization ID from the session
[Section titled “Step 6: Read the organization ID from the session”](#step-6-read-the-organization-id-from-the-session)
Billing routes need org context from the access token. Validate the token on every request and require the `oid` claim. Do not call `/userinfo` for billing context.
lib/auth/require-session.ts
```ts
1
import { decodeJwt } from 'jose';
2
3
const isValid = await scalekit.validateAccessToken(accessToken);
4
if (!isValid) {
5
throw new SessionError(401, 'Invalid or expired token');
6
}
7
8
// Safe after validateAccessToken: signature and standard claims already checked.
9
const claims = decodeJwt(accessToken);
10
11
const organizationId = claims.oid as string | undefined;
12
if (!organizationId) {
13
throw new SessionError(403, 'Organization context required for billing');
14
}
15
16
return {
17
userId: claims.sub as string,
18
email: claims.email as string,
19
organizationId,
20
};
```
## Step 7: Authorize billing references
[Section titled “Step 7: Authorize billing references”](#step-7-authorize-billing-references)
Before any Chargebee API call, confirm the caller’s session org matches the billing reference. Extend with a product hook to deny delinquent orgs without changing Chargebee configuration.
lib/auth/authorize-reference.ts
```ts
1
export type AuthorizeReferenceAction =
2
| 'create'
3
| 'update'
4
| 'cancel'
5
| 'portal'
6
| 'list';
7
8
export async function authorizeReference({
9
userId,
10
organizationId,
11
referenceId,
12
action,
13
}: {
14
userId: string;
15
organizationId: string;
16
referenceId: string;
17
action: AuthorizeReferenceAction;
18
}): Promise {
19
if (referenceId !== organizationId) {
20
return false;
21
}
22
// Optional: return false from onAuthorizeReference to deny specific orgs.
23
return onAuthorizeReference({ userId, organizationId, referenceId, action }) !== false;
24
}
```
**After this step:** a request with `referenceId` that does not match session `oid` returns `403` before Chargebee is called.
## Step 8: Start hosted checkout with a future subscription
[Section titled “Step 8: Start hosted checkout with a future subscription”](#step-8-start-hosted-checkout-with-a-future-subscription)
When an org admin clicks **Subscribe**, create a local `future` row first, stamp pending IDs on the Chargebee customer, then call `hostedPage.checkoutNewForItems`. Use **item price IDs** from your Chargebee product catalog.
api/subscription/create/route.ts
```ts
1
const ctx = await requireSession();
2
const referenceId = body.referenceId ?? ctx.organizationId;
3
4
if (
5
!(await authorizeReference({
6
userId: ctx.userId,
7
organizationId: ctx.organizationId,
8
referenceId,
9
action: 'create',
10
}))
11
) {
12
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
13
}
14
15
const active = await findActiveByReferenceId(referenceId);
16
if (active) {
17
return NextResponse.json(
18
{ error: 'An active subscription already exists for this organization' },
19
{ status: 400 }
20
);
21
}
22
23
const customerId = await getOrCreateCustomerId({
24
organizationId: referenceId,
25
email: ctx.email,
26
});
27
28
const localSub = await createFutureSubscription({
29
referenceId,
30
chargebeeCustomerId: customerId,
31
});
32
33
await chargebee.customer.update(customerId, {
34
meta_data: {
35
pendingSubscriptionId: localSub.id,
36
pendingReferenceId: referenceId,
37
organizationId: referenceId,
38
userId: ctx.userId,
39
},
40
});
41
42
const result = await chargebee.hostedPage.checkoutNewForItems({
43
subscription_items: [{ item_price_id: planItemPriceId, quantity: seats }],
44
customer: { id: customerId },
45
redirect_url: successRedirect, // includes local subscriptionId for eager sync
46
cancel_url: absoluteUrl(cancelUrl),
47
// Optional: pin gateway when Smart Routing cannot auto-select
48
// ...getHostedCheckoutCardOptions(),
49
});
50
51
return NextResponse.json({ mode: 'hosted', url: result.hosted_page.url });
```
The `future` row gives your app a stable ID to reconcile against before Chargebee assigns a subscription ID. Reject create when an active subscription already exists for the org.
**After this step:** `POST /api/subscription/create` returns `{ mode: 'hosted', url }`; completing checkout in the sandbox creates or updates the Chargebee subscription.
In the demo, the billing page lists plans scoped to the session org. **Subscribe** calls your create route, then redirects to Chargebee hosted pages:



## Step 9: Configure Chargebee webhooks
[Section titled “Step 9: Configure Chargebee webhooks”](#step-9-configure-chargebee-webhooks)
In the Chargebee dashboard, create a webhook endpoint that points to:
```text
1
https://your-domain.com/api/webhooks/chargebee
```
Protect the route with HTTP Basic Auth using `CHARGEBEE_WEBHOOK_USERNAME` and `CHARGEBEE_WEBHOOK_PASSWORD`. Enter the same credentials under Basic Authentication in the Chargebee webhook settings.
Subscribe at least to these events (names as in Chargebee / the Node SDK):
| Chargebee event | Action |
| ------------------------------------------------- | --------------------------------------------------- |
| `subscription_created` | Link `chargebee_subscription_id`, set status |
| `subscription_activated` / `subscription_started` | Mark `active` or `in_trial`, run entitlements hooks |
| `subscription_changed` / `subscription_renewed` | Update plan, seats, period dates |
| `subscription_cancelled` | Mark cancelled, revoke entitlements |
| `customer_deleted` | Clear local customer mapping |
Lookup order when matching a webhook to a local row:
1. `chargebee_subscription_id` on the local row
2. Subscription metadata (if you stamp IDs on the Chargebee subscription)
3. `meta_data.pendingSubscriptionId` on the Chargebee customer
4. `future` row by `reference_id`
Chargebee retries on failure
Return `500` when your handler fails so Chargebee retries. Return `200` only after the database write succeeds. Scalekit org webhooks can return `200` immediately and process async — failure modes differ by provider.
api/webhooks/chargebee/route.ts
```ts
1
import { NextRequest, NextResponse } from 'next/server';
2
import {
3
WebhookAuthenticationError,
4
basicAuthValidator,
5
} from 'chargebee';
6
import { processChargebeeWebhookEvent } from '@/lib/billing/chargebee-webhook-handler';
7
8
export async function POST(req: NextRequest) {
9
const username = process.env.CHARGEBEE_WEBHOOK_USERNAME;
10
const password = process.env.CHARGEBEE_WEBHOOK_PASSWORD;
11
12
const headers: Record = {};
13
req.headers.forEach((value, key) => {
14
headers[key.toLowerCase()] = value;
15
});
16
17
if (username && password) {
18
try {
19
await basicAuthValidator(
20
(user, pass) => user === username && pass === password
21
)(headers);
22
} catch (err) {
23
if (err instanceof WebhookAuthenticationError) {
24
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
25
}
26
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
27
}
28
}
29
30
const event = await req.json();
31
await processChargebeeWebhookEvent(event);
32
return NextResponse.json({ received: true });
33
}
```
**After this step:** cancel or change a plan in Chargebee → local subscription status updates without a page refresh loop.
After a successful checkout (and webhook or eager sync), the billing UI shows the live plan status — for example **In Trial** — and the journey marks subscribe and webhook sync as **Done**:

## Step 10: Eager-sync on checkout redirect
[Section titled “Step 10: Eager-sync on checkout redirect”](#step-10-eager-sync-on-checkout-redirect)
Hosted checkout redirects to your success URL before webhooks arrive. Add an eager sync so the billing page shows the subscription immediately. Webhooks remain the source of truth for ongoing changes.
api/subscription/success/route.ts
```ts
1
export async function GET(request: NextRequest) {
2
const subscriptionId = request.nextUrl.searchParams.get('subscriptionId');
3
const callbackURL =
4
request.nextUrl.searchParams.get('callbackURL') ?? '/billing?success=1';
5
6
if (subscriptionId) {
7
const local = await findSubscriptionById(subscriptionId);
8
if (local?.chargebeeSubscriptionId) {
9
const result = await chargebee.subscription.retrieve(
10
local.chargebeeSubscriptionId
11
);
12
await syncLocalFromChargebeeSubscription(local, result.subscription);
13
} else if (local?.chargebeeCustomerId) {
14
// Fallback: list recent subscriptions for the customer and sync the latest
15
const result = await chargebee.subscription.subscriptionsForCustomer(
16
local.chargebeeCustomerId,
17
{ limit: 10 }
18
);
19
// ...sync latest to local row
20
}
21
}
22
23
return NextResponse.redirect(new URL(callbackURL, request.url));
24
}
```
Validate `callbackURL` against an allowlist of relative paths so redirects cannot leave your site.
## Define plans
[Section titled “Define plans”](#define-plans)
Customer provisioning works without a plan catalog in code. To sell plans, map Chargebee **item price IDs** to names, limits, and optional trials. Keep pricing ownership in Chargebee; store entitlement metadata in your app.
**Static configuration** (fine for a small catalog):
lib/billing/plans.ts
```ts
1
export type PlanConfig = {
2
itemPriceId: string;
3
name: string;
4
limits: Record;
5
freeTrial?: { days: number };
6
};
7
8
export const PLANS: PlanConfig[] = [
9
{
10
itemPriceId:
11
process.env.CHARGEBEE_PLAN_ITEM_PRICE_ID ?? 'growth-plan-monthly',
12
name: 'Growth',
13
limits: { seats: 25 },
14
freeTrial: { days: 14 },
15
},
16
];
```
**Dynamic configuration (recommended for maintainability):** load plans from your database so marketing and price IDs stay out of source control. Map rows to the same `PlanConfig` shape and fail closed if the query errors.
## Common flows
[Section titled “Common flows”](#common-flows)
Expand a question when you need that path. Each answer assumes the numbered steps above are in place (schema, webhooks, authorize, hosted checkout).
How do I start hosted checkout for a plan?
Call your create route from the client with the Chargebee **item price ID** and redirect URLs. After authorize and a local `future` row, the server returns a hosted page URL.
app/billing/checkout (client)
```ts
1
const res = await fetch('/api/subscription/create', {
2
method: 'POST',
3
headers: { 'Content-Type': 'application/json' },
4
body: JSON.stringify({
5
itemPriceId: 'growth-plan-monthly',
6
successUrl: '/billing?success=1',
7
cancelUrl: '/billing',
8
}),
9
});
10
const data = await res.json();
11
if (data.url) {
12
window.location.href = data.url;
13
}
```
How do I list active subscriptions for the current org?
Read from your **local** cache after `authorizeReference` — not from Chargebee on every request. Use that result for billing UI and feature gates.
api/subscription/list/route.ts
```ts
1
const subs = await findActiveByReferenceId(ctx.organizationId);
2
return NextResponse.json({
3
subscriptions: subs.map((sub) => ({
4
id: sub.id,
5
status: sub.status,
6
seats: sub.seats,
7
trialEnd: sub.trialEnd,
8
periodEnd: sub.periodEnd,
9
})),
10
});
```
How do I change plans when a subscription already exists?
Use an update route that authorizes the reference, then opens Chargebee hosted update (or applies a subscription change API) for the existing `chargebee_subscription_id`.
Do not call create again while an active subscription exists. Return a clear error (for example `400` with “active subscription already exists”) so the UI can open the portal or update flow instead.
How do I send users to the Chargebee billing portal?
Create a portal session for the org’s Chargebee customer and redirect to `access_url` so they manage payment methods and invoices.
api/subscription/portal/route.ts
```ts
1
// After authorizeReference(..., action: 'portal')
2
const portalSession = await chargebee.portalSession.create({
3
customer: { id: chargebeeCustomerId },
4
redirect_url: absoluteUrl(returnUrl),
5
});
6
return NextResponse.json({
7
url: portalSession.portal_session.access_url,
8
});
```
How do I gate a feature behind an active plan?
Check local subscription status (and plan line items if you store them). Treat `active` and `in_trial` as entitled unless your product rules differ.
lib/billing/require-active-plan.ts
```ts
1
async function requireActivePlan(
2
organizationId: string,
3
planItemPriceId: string
4
): Promise {
5
const subs = await findActiveByReferenceId(organizationId);
6
// Join subscription_item or stored plan metadata as your schema requires
7
return subs.some(
8
(sub) =>
9
(sub.status === 'active' || sub.status === 'in_trial') &&
10
/* plan matches planItemPriceId */
11
true
12
);
13
}
```
How do I restrict who can create or update org billing?
Org-mode billing already scopes by session `oid` via `authorizeReference`. Tighten further by combining that check with role checks (owner/admin) from the Scalekit token or your membership store before calling Chargebee.
## Customer customization (optional)
[Section titled “Customer customization (optional)”](#customer-customization-optional)
Use hooks so product logic stays out of webhook routes.
lib/subscription-hooks.ts
```ts
1
export async function onCustomerCreate(params: {
2
organizationId: string;
3
chargebeeCustomerId: string;
4
displayName?: string | null;
5
}): Promise {
6
// Analytics, CRM sync, internal tenant linking
7
}
8
9
export async function onSubscriptionComplete(ctx: {
10
referenceId: string;
11
subscriptionId: string;
12
chargebeeSubscriptionId: string;
13
status: string;
14
}): Promise {
15
// Enable SSO, flip feature flags, send onboarding email
16
}
17
18
/** Return false to deny billing actions for a reference. */
19
export async function onAuthorizeReference(_params: {
20
userId: string;
21
organizationId: string;
22
referenceId: string;
23
action: 'create' | 'update' | 'cancel' | 'portal' | 'list';
24
}): Promise {
25
return true;
26
}
```
## Database schema overview
[Section titled “Database schema overview”](#database-schema-overview)
| Model | Role |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| **`organization`** | Optional `chargebee_customer_id`; primary key is the Scalekit org ID |
| **`subscription`** | `reference_id` (org), Chargebee subscription/customer IDs, status, trial and period dates, seats, metadata |
| **`subscription_item`** | Line items (plans, addons, charges) with quantity and pricing details |
**Source of truth:** Chargebee owns catalog, invoices, and payment state. Your database is a cache for authorization and UI. After schema changes, migrate your app database the same way you migrate other tables—there is no separate billing CLI.
## Testing
[Section titled “Testing”](#testing)
Run this validation after wiring both webhook endpoints through a tunnel:
1. **Create an organization** in Scalekit (or fire `organization.created` from the dashboard).
2. **Confirm provisioning** — local `organization` row exists; Chargebee shows a customer with matching `organizationId` metadata.
3. **Sign in** as a user in that org and open your billing page.
4. **Start checkout** — `POST /api/subscription/create` returns `{ mode: 'hosted', url }`. Complete payment with test card `4111 1111 1111 1111`.
5. **Confirm redirect** — browser lands on `/billing?success=1` and the subscription appears without a manual refresh.
6. **Replay a webhook** — send a test `subscription_activated` event from Chargebee and confirm the local row updates.
Check session org context
```bash
1
curl -s http://localhost:3000/api/session \
2
-H "Cookie: scalekit_session=" | jq '.organizationId'
```
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
| Symptom | What to check |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Orphan org / no Chargebee customer | Scalekit webhook URL and events; `SCALEKIT_WEBHOOK_SECRET`; signature uses **raw** body; handler logs |
| Webhooks ignored (Chargebee) | URL path; Basic Auth credentials match env; selected events; tunnel health |
| Checkout OK, UI still free tier | Was a `future` row created? Is `pendingSubscriptionId` on the customer? Eager sync route? Chargebee webhooks delivering? |
| Status out of date | `chargebee_customer_id` / `chargebee_subscription_id` populated? Event types subscribed? Handler returns 500 on DB failure so Chargebee retries? |
| Billing API returns 403 | `referenceId` must equal session `oid`; do not key customers by email alone |
| `no_applicable_gateway` on hosted checkout | Add a test gateway; set `CHARGEBEE_GATEWAY_ACCOUNT_ID`; enable Smart Routing |
| Checkout succeeds but no redirect | `NEXT_PUBLIC_APP_URL` must be in Chargebee **Allowed redirect domains**; declined cards prevent redirect |
| Duplicate Chargebee customers | Race between org webhook and first checkout — make `createOrgCustomer` idempotent |
## Production notes
[Section titled “Production notes”](#production-notes)
* **Replace SQLite** with Postgres or your production database. Keep the `reference_id` index.
* **Rotate webhook secrets** independently for Scalekit and Chargebee. Store them in a secrets manager.
* **Make handlers idempotent** — Chargebee retries; upsert by `chargebee_subscription_id`.
* **Handle org deletion** — on `organization.deleted`, cancel active Chargebee subscriptions and delete local rows so billing does not continue.
* **Do not expose Chargebee secret API keys client-side** — only publishable keys belong in `NEXT_PUBLIC_*` variables.
## Helpful prompts
[Section titled “Helpful prompts”](#helpful-prompts)
Use these FAQ-style prompts with an AI coding agent (Cursor, Claude Code, Copilot CLI, Codex, and similar). First install the Scalekit authstack plugin for your agent, then paste a prompt from a section below (replace bracketed placeholders with your stack).
### 1. Set up your coding agent
[Section titled “1. Set up your coding agent”](#1-set-up-your-coding-agent)
Run the Scalekit CLI setup so your agent loads Scalekit auth patterns (reduces hallucinations on sessions, webhooks, and orgs):
Terminal
```bash
1
npx @scalekit-inc/cli setup
```
For repeated use:
Terminal
```bash
1
npm install -g @scalekit-inc/cli
2
scalekit setup
```
`setup` with no arguments launches an interactive wizard and detects installed tools. Target one agent explicitly if you prefer:
Terminal
```bash
1
npx @scalekit-inc/cli setup cursor
2
npx @scalekit-inc/cli setup claude
3
npx @scalekit-inc/cli setup codex
4
npx @scalekit-inc/cli setup copilot
```
See the [Scalekit CLI](/dev-kit/cli/) for flags and what gets installed. After setup, open your agent and use a prompt below.
### 2. Prompts (after setup)
[Section titled “2. Prompts (after setup)”](#2-prompts-after-setup)
How do I scaffold Scalekit webhooks and Chargebee customer provisioning?
```text
I'm integrating Chargebee with Scalekit organizations (org-mode billing). Generate:
1. Scalekit webhook route that verifies the raw body with verifyWebhookPayload
2. createOrgCustomer that upserts local org and creates a Chargebee customer with
meta_data.organizationId and customerType organization
3. authorizeReference requiring referenceId === session oid
My framework is [Next.js App Router / Express / Hono]. Database is [Drizzle / Prisma / Kysely].
```
How do I scaffold subscription create with a future row and hosted checkout?
```text
Write POST /api/subscription/create that:
- requireSession with oid
- authorizeReference for action create
- rejects if an active subscription exists for the org
- creates a local future subscription row
- stamps pendingSubscriptionId on the Chargebee customer metadata
- calls hostedPage.checkoutNewForItems with item price IDs
Return { mode: "hosted", url }.
```
How do I test Scalekit and Chargebee webhooks locally?
```text
Walk me through testing Scalekit and Chargebee webhooks locally with ngrok.
App runs on port 3000. Endpoints: /api/webhooks/scalekit and /api/webhooks/chargebee.
Include ngrok command, dashboard URLs to register, SCALEKIT_WEBHOOK_SECRET,
and CHARGEBEE_WEBHOOK_USERNAME/PASSWORD Basic Auth.
```
How do I generate a pricing page that starts checkout?
```text
Generate a React pricing page that calls POST /api/subscription/create with
itemPriceId, successUrl /billing?success=1, cancelUrl /billing.
Show loading while redirecting. If the API says an active subscription exists,
call POST /api/subscription/portal instead.
```
How do I implement a feature gate for an active plan?
```text
Using a local subscriptions table keyed by Scalekit organization ID, write
requireActivePlan(organizationId, itemPriceId) that returns true only for
active or in_trial rows. Show usage in a Next.js API route.
```
Why are my webhooks not reaching the server?
```text
My Scalekit or Chargebee webhooks are not reaching the server. Auth base paths
are /api/webhooks/scalekit and /api/webhooks/chargebee. Host is [Vercel / Railway / VPS].
Walk through DNS, routing, middleware, signature/Basic Auth, and env configuration.
```
Why is subscription status not updating after checkout or cancel?
```text
Subscription status in my database does not update after checkout or cancel.
Setup: Scalekit organizations + Chargebee, local future row + pendingSubscriptionId.
List likely causes and how to verify chargebee_customer_id and
chargebee_subscription_id are populated.
```
## Resources
[Section titled “Resources”](#resources)
* [saas-auth-chargebee-example](https://github.com/scalekit-developers/saas-auth-chargebee-example) — runnable reference app
* [External IDs and metadata](/guides/external-ids-and-metadata/) — map Scalekit orgs to internal tenant IDs
* [Implement webhooks](https://docs.scalekit.com/authenticate/implement-workflows/implement-webhooks/#_top) — webhook reference
---
# DOCUMENT BOUNDARY
---
# Overview of modelling users and organizations
> Put together a data model for your app's users and organizations
Authenticated users now have access to your app.
Now is the time to consider how you’ll structure your data model for users and organizations. This foundational model will serve you well as you implement features such as workspaces, user invitations, role-based access control, and more. Ultimately, this enables your application to fully support B2B use cases.
Organizations and Users are the two first-class entities in Scalekit
* An **Organization** serves as a dedicated tenant within the application, representing a distinct entity like a company or project. A **User** is an individual account granted access to interact with the application. Typically belong to organization(s).
This is a simplified view of the relationship between these two entities

This model makes it easy to implement essential B2B capabilities in your application.
## Flexible user sign-in options for organizations
[Section titled “Flexible user sign-in options for organizations”](#flexible-user-sign-in-options-for-organizations)
Configure your application to support multiple authentication methods, allowing users to choose their preferred sign-in options.
Also, this is crucial for enabling organization administrators to set and enforce specific authentication policies for their users.
A primary use case is implementing enterprise Single Sign-On (SSO). This allows your customers to authenticate their users through their organization’s existing Identity Provider (IdP), such as Okta, Google, or Microsoft Entra ID where IdP verifies the user’s identity, granting them secure access to your application.
With Scalekit as your authentication platform, administrators can easily enforce authentication policies for their organization’s users. Scalekit handles this enforcement automatically, either applying organization-specific policies or defaulting to your application’s preferred authentication methods on the login page. Configuring these settings is straightforward—simply toggle the desired options in your Scalekit environment through the dashboard or API.
#### User records deduplication
[Section titled “User records deduplication”](#user-records-deduplication)
Regardless of which authentication methods your users choose, Scalekit automatically recognizes users with identical email addresses as the same individual. This eliminates the need for your application to manage multiple user records for the same person and ensures consistent identity recognition across different authentication flows.
* Two different Users cannot have the same email address within the same Scalekit environment.
* Scalekit automatically consolidates accounts. If a user logs in with an email and password and later uses Google OAuth with the same email, both authentication methods will be linked to the same User record.
## On how users join and leave organizations
[Section titled “On how users join and leave organizations”](#on-how-users-join-and-leave-organizations)
Control how users join and are provisioned into organizations. Scalekit provides a flexible user provisioning engine to manage the entire user lifecycle.
This includes:
* Sending and managing user invitations.
* Allowing users to discover and join organizations based on their email domain.
* Enabling membership in multiple organizations.
* Securely de-provisioning users when they leave an organization.
These capabilities are built-in, allowing you to deliver a secure and seamless user management experience from day one.
## Enforce user roles and permissions
[Section titled “Enforce user roles and permissions”](#enforce-user-roles-and-permissions)
While your product may offer a wide range of features, not all users should have identical access or capabilities. For example, in a project management tool, you might allow some users to create projects, while others may have permission only to view them.
Managing user permissions can be complex. Scalekit simplifies this by providing the necessary roles and permissions your application needs to make authorization decisions at runtime.
When a user [completes the login flow](/authenticate/fsa/complete-login/#decoding-token-claims), the access token issued by Scalekit contains their assigned roles. Your application can inspect this token to control access to different features. By default, Scalekit assigns an `admin` role to the organization creator and a `member` role to all other users, providing a solid foundation for your authorization logic.
## Modify user memberships
[Section titled “Modify user memberships”](#modify-user-memberships)
Scalekit tracks how users belong to organizations through a `memberships` property on each User object. This property contains an array of membership objects that define the user’s relationship to each organization they belong to.
Each membership object includes these key properties:
* `organization_id`: Identifies which organization the user belongs to
* `roles`: Specifies the user’s roles (assigned by your application) within that organization
* `status`: Indicates whether the membership is active, pending invite or invite expired
The memberships property enables users to belong to multiple organizations while maintaining clear role and status information for each relationship.
```json
1
{
2
"memberships": [
3
{
4
"join_time": "2025-06-27T10:57:43.720Z",
5
"membership_status": "ACTIVE",
6
"metadata": {
7
"department": "engineering",
8
"location": "nyc-office"
9
},
10
"name": "string",
11
"organization_id": "org_1234abcd5678efgh",
12
"primary_identity_provider": "OKTA",
13
"roles": [
14
{
15
"id": "role_admin",
16
"name": "Admin"
17
}
18
]
19
},
20
{
21
"join_time": "2025-07-15T14:30:22.451Z",
22
"membership_status": "ACTIVE",
23
"metadata": {
24
"department": "product",
25
"location": "sf-office"
26
},
27
"name": "Jane Smith",
28
"organization_id": "org_9876zyxw5432vuts",
29
"primary_identity_provider": "GOOGLE",
30
"roles": [
31
{
32
"id": "role_prod_manager",
33
"name": "Product Manager"
34
}
35
]
36
}
37
],
38
}
```
#### Migrating from a 1-to-1 model
[Section titled “Migrating from a 1-to-1 model”](#migrating-from-a-1-to-1-model)
In a 1-to-1 data model, each user is associated with a single organization. The user’s identity is tied to that specific organization, and they cannot belong to multiple organizations with the same identity. This model is common in applications that were not originally built with multi-tenancy in mind, or where each customer’s data and user base are kept entirely separate.
For example, many traditional enterprise software applications like **Slack**, **QuickBooks**, or **Adobe Creative Suite** use this model - each customer purchases their own license and has their own separate user accounts that cannot be shared across different customer organizations.
#### Migrating from a 1-to-many model
[Section titled “Migrating from a 1-to-many model”](#migrating-from-a-1-to-many-model)
If your application allows a single user to be part of multiple organizations, their profile in Scalekit will also be shared across those organizations. While the user’s core profile is consistent, each organization membership stores distinct information like roles, status, and metadata.
If you already have a membership table that links users and organizations, you can add the Scalekit `user_id` to that table. When you update a user’s profile, the changes will apply across all their organization memberships.
| Aspect | 1-to-1 | 1-to-many |
| ------------------- | ------------------------------- | ------------------------------- |
| **User belongs to** | One organization | Multiple organizations |
| **Email address** | Tied to one org | Unique across environment |
| **Authentication** | Per-organization | Across all orgs |
| **Example apps** | Adobe Creative, QuickBooks | Slack, GitHub, Figma |
| **Scalekit use** | Simpler setup, less flexibility | Full multi-tenancy capabilities |
---
# DOCUMENT BOUNDARY
---
# Set up environment & SDK
> Create your account, install SDK, set up AI tools, and verify your setup to start building with Scalekit
Create a Scalekit account, install the SDK, configure your credentials, and verify the setup. This prepares your environment for adding authentication to your application.
Before you begin, create a Scalekit account if you haven’t already. After creating your account, a Scalekit workspace is automatically set up for you with dedicated development and production environments.
[Create a Scalekit account](https://app.scalekit.com/ws/signup)
1. ## Get your API credentials
[Section titled “Get your API credentials”](#get-your-api-credentials)
Scalekit uses the OAuth 2.0 client credentials flow for secure API authentication.
Navigate to **Dashboard > Developers > Settings > API credentials** and copy these values:
.env
```sh
SCALEKIT_ENVIRONMENT_URL= # Example: https://acme.scalekit.dev or https://auth.acme.com (if custom domain is set)
SCALEKIT_CLIENT_ID= # Example: skc_1234567890abcdef
SCALEKIT_CLIENT_SECRET= # Example: test_abcdef1234567890
```
Your workspace includes two environment URLs:
Environment URLs
```md
https://{your-subdomain}.scalekit.dev (Development)
https://{your-subdomain}.scalekit.com (Production)
```
View your environment URLs in **Dashboard > Developers > Settings**.
2. ## Install and initialize the SDK
[Section titled “Install and initialize the SDK”](#install-and-initialize-the-sdk)
Choose your preferred language and install the Scalekit SDK:
* Node.js
```bash
npm install @scalekit-sdk/node
```
* Python
```sh
pip install scalekit-sdk-python
```
* Go
```sh
go get -u github.com/scalekit-inc/scalekit-sdk-go
```
* Java
```groovy
/* Gradle users - add the following to your dependencies in build file */
implementation "com.scalekit:scalekit-sdk-java:2.1.3"
```
```xml
com.scalekit
scalekit-sdk-java
2.1.3
```
After installation, initialize the SDK with your credentials:
* Node.js
Initialize SDK
```js
1
import { Scalekit } from '@scalekit-sdk/node';
2
3
// Initialize the Scalekit client with your credentials
4
const scalekit = new Scalekit(
5
process.env.SCALEKIT_ENVIRONMENT_URL,
6
process.env.SCALEKIT_CLIENT_ID,
7
process.env.SCALEKIT_CLIENT_SECRET
8
);
```
* Python
Initialize SDK
```python
1
from scalekit import ScalekitClient
2
import os
3
4
# Initialize the Scalekit client with your credentials
5
scalekit_client = ScalekitClient(
6
env_url=os.getenv('SCALEKIT_ENVIRONMENT_URL'),
7
client_id=os.getenv('SCALEKIT_CLIENT_ID'),
8
client_secret=os.getenv('SCALEKIT_CLIENT_SECRET')
9
)
```
* Go
Initialize SDK
```go
1
import (
2
"os"
3
"github.com/scalekit-inc/scalekit-sdk-go"
4
)
5
6
// Initialize the Scalekit client with your credentials
7
scalekitClient := scalekit.NewScalekitClient(
8
os.Getenv("SCALEKIT_ENVIRONMENT_URL"),
9
os.Getenv("SCALEKIT_CLIENT_ID"),
10
os.Getenv("SCALEKIT_CLIENT_SECRET"),
11
)
```
* Java
Initialize SDK
```java
1
import com.scalekit.ScalekitClient;
2
3
// Initialize the Scalekit client with your credentials
4
ScalekitClient scalekitClient = new ScalekitClient(
5
System.getenv("SCALEKIT_ENVIRONMENT_URL"),
6
System.getenv("SCALEKIT_CLIENT_ID"),
7
System.getenv("SCALEKIT_CLIENT_SECRET")
8
);
```
SDK features
All official SDKs include automatic retries, error handling, typed models, and auth helper methods to simplify your integration.
3. ## Verify your setup
[Section titled “Verify your setup”](#verify-your-setup)
Test your configuration by listing organizations in your workspace. This confirms your credentials work correctly.
* cURL
Authenticate with client credentials
```bash
# Get an access token
curl https:///oauth/token \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'client_id=' \
-d 'client_secret=' \
-d 'grant_type=client_credentials'
```
This returns an access token:
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsImInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 86399,
"scope": "openid"
}
```
Use the token to access the Scalekit API
List organizations
```sh
curl -L '/api/v1/organizations?page_size=5' \
-H 'Authorization: Bearer '
```
* Node.js
Create a file `verify.js` with the following code:
verify.js
```javascript
8 collapsed lines
import { ScalekitClient } from '@scalekit-sdk/node';
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENVIRONMENT_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET,
);
const { organizations } = await scalekit.organization.listOrganization({
pageSize: 5,
});
console.log(`Name of the first organization: ${organizations[0].display_name}`);
```
Run the verification script:
Run verification
```bash
node verify.js
```
* Python
Create a file `verify.py` with the following code:
verify.py
```python
9 collapsed lines
from scalekit import ScalekitClient
import os
# Initialize the SDK client
scalekit_client = ScalekitClient(
os.getenv('SCALEKIT_ENVIRONMENT_URL'),
os.getenv('SCALEKIT_CLIENT_ID'),
os.getenv('SCALEKIT_CLIENT_SECRET')
)
org_list = scalekit_client.organization.list_organizations(page_size=5)
print(f'Name of the first organization: {org_list[0].display_name}')
```
Run the verification script:
Run verification
```bash
python verify.py
```
* Go
Create a file `verify.go` with the following code:
verify.go
```go
18 collapsed lines
package main
import (
"context"
"fmt"
"os"
"github.com/scalekit-inc/scalekit-sdk-go"
)
func main() {
ctx := context.Background()
scalekitClient := scalekit.NewScalekitClient(
os.Getenv("SCALEKIT_ENVIRONMENT_URL"),
os.Getenv("SCALEKIT_CLIENT_ID"),
os.Getenv("SCALEKIT_CLIENT_SECRET"),
)
organizations, err := scalekitClient.Organization.ListOrganizations(ctx, &scalekit.ListOrganizationsParams{
PageSize: 5,
})
4 collapsed lines
if err != nil {
panic(err)
}
fmt.Printf("Name of the first organization: %s\n", organizations[0].DisplayName)
}
```
* Java
Create a file `Verify.java` with the following code:
Verify.java
```java
7 collapsed lines
import com.scalekit.ScalekitClient;
import com.scalekit.models.ListOrganizationsResponse;
public class Verify {
public static void main(String[] args) {
ScalekitClient scalekitClient = new ScalekitClient(
System.getenv("SCALEKIT_ENVIRONMENT_URL"),
System.getenv("SCALEKIT_CLIENT_ID"),
System.getenv("SCALEKIT_CLIENT_SECRET")
);
ListOrganizationsResponse organizations = scalekitClient.organizations().listOrganizations(5, "");
System.out.println("Name of the first organization: " + organizations.getOrganizations()[0].getDisplayName());
}
}
```
If you see organization data, your setup is complete! You’re now ready to implement authentication in your application.
## Set up Scalekit MCP Server Optional
[Section titled “Set up Scalekit MCP Server ”](#set-up-scalekit-mcp-server-)
Scalekit’s Model Context Protocol (MCP) server connects your AI coding assistants to Scalekit. Manage environments, organizations, users, and authentication through natural language queries in your MCP client.
The MCP server provides AI assistants with tools for environment management, organization and user management, authentication connection setup, role administration, and admin portal access. It uses OAuth 2.1 authentication to securely connect your AI tools to your Scalekit workspace.
Building your own MCP server?
If you’re building your own MCP server and need to add OAuth-based authorization, check out our guide: [Add auth to your MCP server](/authenticate/mcp/quickstart/).
### Configure your MCP client
[Section titled “Configure your MCP client”](#configure-your-mcp-client)
Use the most common client configs below. For the full list of supported MCP hosts and editor setups, see the [Scalekit MCP server guide](/dev-kit/ai-assisted-development/scalekit-mcp-server/).
* Claude Code
Run this command in your terminal:
Terminal
```bash
1
claude mcp add --transport http scalekit https://mcp.scalekit.com/
```
* Cursor
Edit `~/.cursor/mcp.json`, or open **Cursor Settings → MCP → Add New Global MCP Server** and paste the config:
\~/.cursor/mcp.json
```json
{
"mcpServers": {
"scalekit": {
"url": "https://mcp.scalekit.com/"
}
}
}
```
* Codex
Run this command in your terminal:
Terminal
```bash
1
codex mcp add scalekit --url https://mcp.scalekit.com/
```
* OpenCode
Edit `opencode.json` in your project root:
opencode.json
```json
{
"mcp": {
"scalekit": {
"type": "remote",
"url": "https://mcp.scalekit.com/"
}
}
}
```
After configuration, your MCP client will initiate an OAuth authorization workflow to securely connect to Scalekit’s MCP server.
Note
For Claude Desktop, VS Code, Windsurf, Gemini CLI, Kiro, Warp, Zed, and other hosts, use the full [Scalekit MCP server guide](/dev-kit/ai-assisted-development/scalekit-mcp-server/).
## Configure code editors for Scalekit documentation
[Section titled “Configure code editors for Scalekit documentation”](#configure-code-editors-for-scalekit-documentation)
In-code editor chat features are powered by models that understand your codebase and project context. These models search the web for relevant information to help you. However, they may not always have the latest information. Follow the instructions below to configure your code editors to explicitly index for up-to-date information.
### Set up Cursor
[Section titled “Set up Cursor”](#set-up-cursor)
[Play](https://youtube.com/watch?v=oMMG1k_9fmU)
To enable Cursor to access up-to-date Scalekit documentation:
1. Open Cursor settings (Cmd/Ctrl + ,)
2. Navigate to **Indexing & Docs** section
3. Click on **Add**
4. Add `https://docs.scalekit.com/llms-full.txt` to the indexable URLs
5. Click on **Save**
Once configured, use `@Scalekit Docs` in your chat to ask questions about Scalekit features, APIs, and integration guides. Cursor will search the latest documentation to provide accurate, up-to-date answers.
### Use Windsurf
[Section titled “Use Windsurf”](#use-windsurf)

Windsurf enables `@docs` mentions within the Cascade chat to search for the best answers to your questions.
* Full Documentation
```plaintext
1
@docs:https://docs.scalekit.com/llms-full.txt
2
```
Costs more tokens.
* Specific Section
```plaintext
1
@docs:https://docs.scalekit.com/your-specific-section-or-file
2
```
Costs less tokens.
* Let AI decide
```plaintext
1
@docs:https://docs.scalekit.com/llms.txt
2
```
Costs tokens as per the model decisions.
## Use AI assistants
[Section titled “Use AI assistants”](#use-ai-assistants)
Assistants like **Anthropic Claude**, **Ollama**, **Google Gemini**, **Vercel v0**, **OpenAI’s ChatGPT**, or your own models can help you with Scalekit projects.
[Play](https://youtube.com/watch?v=ZDAI32I6s-I)
Need help with a specific AI tool?
Don’t see instructions for your favorite AI assistant? We’d love to add support for more tools! [Raise an issue](https://github.com/scalekit-inc/developer-docs/issues) on our GitHub repository and let us know which AI tool you’d like us to document.
---
# DOCUMENT BOUNDARY
---
# Complete login with code exchange
> Process authentication callbacks and handle redirect flows after users authenticate with Scalekit
Once users have successfully verified their identity using their chosen login method, Scalekit will have gathered the necessary user information for your app to complete the login process. However, your app must provide a callback endpoint where Scalekit can exchange an authorization code to return your app the user details.
1. ## Validate the `state` parameter recommended
[Section titled “Validate the state parameter ”](#validate-the-state-parameter-)
Before exchanging the authorization code, your application must validate the `state` parameter returned by Scalekit. Compare it with the value you stored in the user’s session before redirecting them. This critical step prevents Cross-Site Request Forgery (CSRF) attacks, ensuring the authentication response corresponds to a request initiated by the same user.
* Node.js
Validate state in Express.js
```javascript
1
const { state } = req.query;
2
3
// Assumes you are using a session middleware like express-session
4
const storedState = req.session.oauthState;
5
delete req.session.oauthState; // State should be used only once
6
7
if (!state || state !== storedState) {
8
console.error('Invalid state parameter');
9
return res.redirect('/login?error=invalid_state');
10
}
```
* Python
Validate state in Flask
```python
1
from flask import session, request, redirect
2
3
state = request.args.get('state')
4
5
# Retrieve and remove stored state from session
6
stored_state = session.pop('oauth_state', None)
7
8
if not state or state != stored_state:
9
print('Invalid state parameter')
10
return redirect('/login?error=invalid_state')
```
* Go
Validate state in Gin
```go
1
stateParam := c.Query("state")
2
3
// Assumes you are using a session library like gin-contrib/sessions
4
session := sessions.Default(c)
5
storedState := session.Get("oauth_state")
6
session.Delete("oauth_state") // State should be used only once
7
session.Save()
8
9
if stateParam == "" || stateParam != storedState {
10
log.Println("Invalid state parameter")
11
c.Redirect(http.StatusFound, "/login?error=invalid_state")
12
return
13
}
```
* Java
Validate state in Spring
```java
1
// Assumes HttpSession is injected into your controller method
2
String storedState = (String) session.getAttribute("oauth_state");
3
session.removeAttribute("oauth_state"); // State should be used only once
4
5
if (state == null || !state.equals(storedState)) {
6
System.err.println("Invalid state parameter");
7
return new RedirectView("/login?error=invalid_state");
8
}
```
2. ## Exchange authorization code for tokens
[Section titled “Exchange authorization code for tokens”](#exchange-authorization-code-for-tokens)
Once the `state` is validated, your app can safely exchange the authorization code for tokens. The Scalekit SDK simplifies this process with the `authenticateWithCode` method, which handles the secure server-to-server request.
* Node.js
Express.js callback handler
```javascript
1
app.get('/auth/callback', async (req, res) => {
2
const { code, error, error_description, state } = req.query;
3
4
// Add state validation here (see previous step)
11 collapsed lines
5
6
// Handle errors first
7
if (error) {
8
console.error('Authentication error:', error);
9
return res.redirect('/login?error=auth_failed');
10
}
11
12
if (!code) {
13
return res.redirect('/login?error=missing_code');
14
}
15
16
try {
17
// Exchange code for user data
18
const authResult = await scalekit.authenticateWithCode(
19
code,
20
'https://yourapp.com/auth/callback'
21
);
22
23
const { user, accessToken, refreshToken } = authResult;
11 collapsed lines
24
25
// TODO: Store user session (next guide covers this)
26
// req.session.user = user;
27
28
res.redirect('/dashboard');
29
30
} catch (error) {
31
console.error('Token exchange failed:', error);
32
res.redirect('/login?error=exchange_failed');
33
}
34
});
```
* Python
Flask callback handler
```python
1
@app.route('/auth/callback')
2
def auth_callback():
3
code = request.args.get('code')
4
error = request.args.get('error')
9 collapsed lines
5
state = request.args.get('state')
6
7
# TODO: Add state validation here (see previous step)
8
9
# Handle errors first
10
if error:
11
print(f'Authentication error: {error}')
12
return redirect('/login?error=auth_failed')
13
14
if not code:
15
return redirect('/login?error=missing_code')
16
17
try:
18
# Exchange code for user data
19
options = CodeAuthenticationOptions()
20
auth_result = scalekit.authenticate_with_code(
21
code,
22
'https://yourapp.com/auth/callback',
23
options
24
)
25
26
user = auth_result.user
27
# access_token = auth_result.access_token
28
# refresh_token = auth_result.refresh_token
6 collapsed lines
29
30
# TODO: Store user session (next guide covers this)
31
# session['user'] = user
32
33
return redirect('/dashboard')
34
35
except Exception as e:
36
print(f'Token exchange failed: {e}')
37
return redirect('/login?error=exchange_failed')
```
* Go
Gin callback handler
```go
1
func authCallbackHandler(c *gin.Context) {
2
code := c.Query("code")
3
errorParam := c.Query("error")
13 collapsed lines
4
stateParam := c.Query("state")
5
6
// TODO: Add state validation here (see previous step)
7
8
// Handle errors first
9
if errorParam != "" {
10
log.Printf("Authentication error: %s", errorParam)
11
c.Redirect(http.StatusFound, "/login?error=auth_failed")
12
return
13
}
14
15
if code == "" {
16
c.Redirect(http.StatusFound, "/login?error=missing_code")
17
return
18
}
19
20
// Exchange code for user data
21
options := scalekit.AuthenticationOptions{}
22
authResult, err := scalekitClient.AuthenticateWithCode(
23
c.Request.Context(), code,
7 collapsed lines
24
"https://yourapp.com/auth/callback",
25
options,
26
)
27
28
if err != nil {
29
log.Printf("Token exchange failed: %v", err)
30
c.Redirect(http.StatusFound, "/login?error=exchange_failed")
31
return
32
}
33
34
user := authResult.User
35
// accessToken := authResult.AccessToken
36
// refreshToken := authResult.RefreshToken
37
38
// TODO: Store user session (next guide covers this)
39
// session.Set("user", user)
40
41
c.Redirect(http.StatusFound, "/dashboard")
42
}
```
* Java
Spring callback handler
```java
1
@GetMapping("/auth/callback")
2
public Object authCallback(
3
@RequestParam(required = false) String code,
4
@RequestParam(required = false) String error,
5
@RequestParam(required = false) String state,
10 collapsed lines
6
HttpSession session
7
) {
8
// TODO: Add state validation here (see previous step)
9
10
// Handle errors first
11
if (error != null) {
12
System.err.println("Authentication error: " + error);
13
return new RedirectView("/login?error=auth_failed");
14
}
15
16
if (code == null) {
17
return new RedirectView("/login?error=missing_code");
18
}
19
20
try {
21
// Exchange code for user data
22
AuthenticationOptions options = new AuthenticationOptions();
23
AuthenticationResponse authResult = scalekit
24
.authentication()
25
.authenticateWithCode(code, "https://yourapp.com/auth/callback", options);
26
27
var user = authResult.getIdTokenClaims();
28
// String accessToken = authResult.getAccessToken();
29
// String refreshToken = authResult.getRefreshToken();
30
6 collapsed lines
31
// TODO: Store user session (next guide covers this)
32
// session.setAttribute("user", user);
33
34
return new RedirectView("/dashboard");
35
36
} catch (Exception e) {
37
System.err.println("Token exchange failed: " + e.getMessage());
38
return new RedirectView("/login?error=exchange_failed");
39
}
40
}
```
The authorization `code` can be redeemed only once and expires in approx \~10 minutes. Reuse or replay attempts typically return errors like `invalid_grant`. If this occurs, start a new login flow to obtain a fresh `code` and `state`.
The `authResult` object returned contains:
```js
{
user: {
email: "john.doe@example.com",
emailVerified: true,
givenName: "John",
name: "John Doe",
id: "usr_74599896446906854"
},
idToken: "eyJhbGciO..", // Decode for full user details
accessToken: "eyJhbGciOi..",
refreshToken: "rt_8f7d6e5c4b3a2d1e0f9g8h7i6j..",
expiresIn: 299 // in seconds
}
```
| Key | Description |
| -------------- | ------------------------------------------------------------- |
| `user` | Common user details with email, name, and verification status |
| `idToken` | JWT containing verified full user identity claims |
| `accessToken` | Short-lived token that determines current access |
| `refreshToken` | Long-lived token to obtain new access tokens |
3. ## Decoding token claims
[Section titled “Decoding token claims”](#decoding-token-claims)
The `idToken` and `accessToken` are JSON Web Tokens (JWT) that contain user claims. These tokens can be decoded to retrieve comprehensive user and access information.
* Node.js
Decode ID token
```javascript
1
// Use a library like 'jsonwebtoken'
2
const jwt = require('jsonwebtoken');
3
4
// The idToken from the authResult object
5
const { idToken } = authResult;
6
7
// Decode the token without verifying its signature
8
const decoded = jwt.decode(idToken);
9
10
console.log('Decoded claims:', decoded);
```
* Python
Decode ID token
```python
1
# Use a library like 'PyJWT'
2
import jwt
3
4
# The id_token from the auth_result object
5
id_token = auth_result.id_token
6
7
# Decode the token without verifying its signature
8
decoded = jwt.decode(id_token, options={"verify_signature": False})
9
print(f'Decoded claims: {decoded}')
```
* Go
Decode ID token
```go
1
// Use a library like 'github.com/golang-jwt/jwt/v5'
2
import (
3
"fmt"
4
"github.com/golang-jwt/jwt/v5"
5
)
6
7
// The IdToken from the authResult object
8
idToken := authResult.IdToken
9
token, _, err := new(jwt.Parser).ParseUnverified(idToken, jwt.MapClaims{})
10
if err != nil {
11
fmt.Printf("Error parsing token: %v\n", err)
12
return
13
}
14
15
if claims, ok := token.Claims.(jwt.MapClaims); ok {
16
fmt.Printf("Decoded claims: %+v\n", claims)
17
}
```
* Java
Decode ID token
```java
1
// Use a library like 'com.auth0:java-jwt'
2
import com.auth0.jwt.JWT;
3
import com.auth0.jwt.interfaces.DecodedJWT;
4
import com.auth0.jwt.interfaces.Claim;
5
import com.auth0.jwt.exceptions.JWTDecodeException;
6
import java.util.Map;
7
8
try {
9
// The idToken from the authResult object
10
String idToken = authResult.getIdToken();
11
12
// Decode the token without verifying its signature
13
DecodedJWT decodedJwt = JWT.decode(idToken);
14
Map claims = decodedJwt.getClaims();
15
16
System.out.println("Decoded claims: " + claims);
17
} catch (JWTDecodeException exception){
18
// Invalid token
19
System.err.println("Failed to decode ID token: " + exception.getMessage());
20
}
```
The decoded token claims contain:
* Decoded ID token
ID token decoded
```json
1
{
2
"iss": "https://scalekit-z44iroqaaada-dev.scalekit.cloud", // Issuer: Scalekit environment URL (must match your environment)
3
"aud": ["skc_58327482062864390"], // Audience: Your client ID (must match for validation)
4
"azp": "skc_58327482062864390", // Authorized party: Usually same as aud
5
"sub": "usr_63261014140912135", // Subject: User's unique identifier
6
"oid": "org_59615193906282635", // Organization ID: User's organization
7
"exp": 1742975822, // Expiration: Unix timestamp (validate token hasn't expired)
8
"iat": 1742974022, // Issued at: Unix timestamp when token was issued
9
"at_hash": "ec_jU2ZKpFelCKLTRWiRsg", // Access token hash: For token binding validation
10
"c_hash": "6wMreK9kWQQY6O5R0CiiYg", // Authorization code hash: For code binding validation
11
"amr": ["conn_123"], // Authentication method reference: Connection ID used for auth
12
"email": "john.doe@example.com", // User's email address
13
"email_verified": true, // Email verification status
14
"name": "John Doe", // User's full name (optional)
15
"given_name": "John", // User's first name (optional)
16
"family_name": "Doe", // User's last name (optional)
17
"picture": "https://...", // Profile picture URL (optional)
18
"locale": "en", // User's locale preference (optional)
19
"sid": "ses_65274187031249433", // Session ID: Links token to user session
20
"client_id": "skc_58327482062864390", // Client ID: Your application identifier
21
"xoid": "ext_org_123", // External organization ID (if mapped)
22
}
```
* Decoded access token
Decoded access token
```json
1
{
2
"iss": "https://login.devramp.ai", // Issuer: Scalekit environment URL (must match your environment)
3
"aud": ["prd_skc_7848964512134X699"], // Audience: Your client ID (must match for validation)
4
"sub": "usr_8967800122X995270", // Subject: User's unique identifier
5
"oid": "org_89678001X21929734", // Organization ID: User's organization
6
"exp": 1758265247, // Expiration: Unix timestamp (validate token hasn't expired)
7
"iat": 1758264947, // Issued at: Unix timestamp when token was issued
8
"nbf": 1758264947, // Not before: Unix timestamp (token valid from this time)
9
"jti": "tkn_90928731115292X63", // JWT ID: Unique token identifier
10
"sid": "ses_90928729571723X24", // Session ID: Links token to user session
11
"client_id": "prd_skc_7848964512134X699", // Client ID: Your application identifier
12
"roles": ["admin"], // Roles: User roles within organization (optional, for authorization)
13
"permissions": ["workspace_data:write", "workspace_data:read"], // Permissions: resource:action format (optional, for granular access control)
14
"scope": "openid profile email", // OAuth scopes granted (optional)
15
"xoid": "ext_org_123", // External organization ID (if mapped)
16
"xuid": "ext_usr_456" // External user ID (if mapped)
17
}
```
ID token claims reference
ID tokens contain cryptographically signed claims about a user’s profile information. The Scalekit SDK automatically validates ID tokens when you use `authenticateWithCode`. If you need to manually verify or access custom claims, use the claim reference below.
| Claim | Presence | Description |
| ---------------- | -------- | ----------------------------------------------- |
| `iss` | Always | Issuer identifier (Scalekit environment URL) |
| `aud` | Always | Intended audience (your client ID) |
| `sub` | Always | Subject identifier (user’s unique ID) |
| `oid` | Always | Organization ID of the user |
| `exp` | Always | Expiration time (Unix timestamp) |
| `iat` | Always | Issuance time (Unix timestamp) |
| `at_hash` | Always | Access token hash for validation |
| `c_hash` | Always | Authorization code hash for validation |
| `azp` | Always | Authorized presenter (usually same as `aud`) |
| `amr` | Always | Authentication method reference (connection ID) |
| `email` | Always | User’s email address |
| `email_verified` | Optional | Email verification status |
| `name` | Optional | User’s full name |
| `family_name` | Optional | User’s surname or last name |
| `given_name` | Optional | User’s given name or first name |
| `locale` | Optional | User’s locale (BCP 47 language tag) |
| `picture` | Optional | URL of user’s profile picture |
| `sid` | Always | Session identifier |
| `client_id` | Always | Your application’s client ID |
Validate ID tokens without an SDK
Use this flow when you validate Scalekit ID tokens in a language without an official SDK (for example, Ruby on Rails or PHP). The Scalekit SDK validates tokens automatically when you call `authenticateWithCode`.
| Parameter | Value |
| --------------------- | ----------------------------------------------------------------- |
| OpenID configuration | `https:///.well-known/openid-configuration` |
| Issuer (`iss`) | Your Scalekit environment URL |
| JWKS URI (`jwks_uri`) | `https:///keys` |
| Signing algorithm | `RS256` |
1. Fetch `/.well-known/openid-configuration` and read `issuer` and `jwks_uri`.
2. Fetch the JWKS document from `jwks_uri`.
3. Verify the JWT signature with **RS256** using the key whose `kid` matches the token header.
4. Validate `iss`, `aud`, and `exp`. The `aud` claim may be a string or an array of strings. Your application’s client ID (`skc_...`) must appear in `aud` — reject the token if it does not.
Audience may be a string or array
OIDC allows `aud` as either a single string or an array. When `aud` is an array, check that your client ID is included. Ruby’s `verify_aud: true` with a string `aud` option handles both shapes in recent `jwt` gem versions; in other languages, normalize `aud` to a list before comparing.
Ruby on Rails example
```ruby
1
require "jwt"
2
require "net/http"
3
require "json"
4
5
ENV_URL = ENV.fetch("SCALEKIT_ENV_URL")
6
CLIENT_ID = ENV.fetch("SCALEKIT_CLIENT_ID")
7
8
config = JSON.parse(Net::HTTP.get(URI("#{ENV_URL}/.well-known/openid-configuration")))
9
jwks = JSON.parse(Net::HTTP.get(URI(config["jwks_uri"])))
10
11
decoded, = JWT.decode(
12
id_token, nil, true,
13
algorithms: ["RS256"], jwks: jwks,
14
iss: config["issuer"], verify_iss: true,
15
aud: CLIENT_ID, verify_aud: true
16
)
```
Confirm endpoints before wiring validation into your app:
```bash
1
curl -s "https:///.well-known/openid-configuration" | jq '{issuer, jwks_uri}'
2
curl -s "https:///keys" | jq '.keys[] | {kid, alg, use}'
```
See [ID token claims](/guides/idtoken-claims/) for the full claim list.
Access token claims reference
Access tokens contain authorization information including roles and permissions. Use these claims to make authorization decisions in your application.
**Roles** group related permissions together and define what users can do in your system. Common examples include Admin, Manager, Editor, and Viewer. Roles can inherit permissions from other roles, creating hierarchical access levels.
**Permissions** represent specific actions users can perform, formatted as `resource:action` patterns like `projects:create` or `tasks:read`. Use permissions for granular access control when you need precise control over individual capabilities.
Scalekit automatically assigns the `admin` role to the first user in each organization and the `member` role to subsequent users. Your application uses the role and permission information from Scalekit to make final authorization decisions at runtime.
| Claim | Presence | Description |
| ------------- | -------- | ------------------------------------------------ |
| `iss` | Always | Issuer identifier (Scalekit environment URL) |
| `aud` | Always | Intended audience (your client ID) |
| `sub` | Always | Subject identifier (user’s unique ID) |
| `oid` | Always | Organization ID of the user |
| `exp` | Always | Expiration time (Unix timestamp) |
| `iat` | Always | Issuance time (Unix timestamp) |
| `nbf` | Always | Not before time (Unix timestamp) |
| `jti` | Always | JWT ID (unique token identifier) |
| `sid` | Always | Session identifier |
| `client_id` | Always | Client identifier for the application |
| `roles` | Optional | Array of role names assigned to the user |
| `permissions` | Optional | Array of permissions in `resource:action` format |
| `scope` | Optional | Space-separated list of OAuth scopes granted |
Scalekit can include the following metadata claims in access tokens when this feature is enabled for your environment. Self-serve configuration in the Scalekit Dashboard is coming soon. Until then, request access in the [Scalekit Slack community](https://join.slack.com/t/scalekit-community/shared_invite/zt-3gsxwr4hc-0tvhwT2b_qgVSIZQBQCWRw).
| Claim | Source | Description |
| --------------------- | --------------------- | ---------------------------------------------------------------------------- |
| `user_metadata` | User record | Custom key-value pairs attached to the user |
| `membership_metadata` | User’s org membership | Custom key-value pairs attached to the user’s membership in the organization |
| `org_metadata` | Organization record | Custom key-value pairs attached to the organization |
Empty metadata is omitted. Scalekit adds these claims only when the corresponding metadata object is non-empty.
4. ## Verifying access tokens optional
[Section titled “Verifying access tokens ”](#verifying-access-tokens-)
The Scalekit SDK provides methods to validate tokens automatically. When you use the SDK’s `validateAccessToken` method, it:
1. Verifies the token signature using Scalekit’s public keys
2. Checks the token hasn’t expired (`exp` claim)
3. Validates the issuer (`iss` claim) matches your environment
4. Ensures the audience (`aud` claim) matches your client ID
If you need to manually verify tokens, fetch the public signing keys from the JSON Web Key Set (JWKS) endpoint:
JWKS endpoint
```sh
1
https:///keys
```
For example, if your Scalekit Environment URL is `https://your-environment.scalekit.com`, the keys can be found at `https://your-environment.scalekit.com/keys`.
Important claims to validate
When validating tokens manually, pay attention to these claims:
* **`iss` (Issuer)**: Must match your Scalekit environment URL
* **`aud` (Audience)**: Must match your application’s client ID
* **`exp` (Expiration Time)**: Ensure the token has not expired
* **`sub` (Subject)**: Uniquely identifies the user
* **`oid` (Organization ID)**: Identifies which organization the user belongs to
An `IdToken` contains comprehensive profile information about the user. You can save this in your database for app use cases, using [your own identifier](/fsa/guides/organization-identifiers/). Now, let’s utilize *access and refresh tokens* to manage user access and maintain active sessions.
## Common login scenarios
[Section titled “Common login scenarios”](#common-login-scenarios)
Customize the login flow by passing different parameters when creating the authorization URL. These scenarios help you route users to specific organizations, force re-authentication, or direct users to signup.
Include state in production code
The routing examples below omit `state` so the parameter focus stays visible. In production, always set `options.state` to a cryptographically random value, store it server-side before redirecting, and validate it on callback. See [Validate the `state` parameter](#validate-the-state-parameter-) above and [Initiate user login](/authenticate/fsa/implement-login/).
How do I route users to a specific organization?
For multi-tenant applications, you can route users directly to their organization’s authentication method using `organizationId`. This is useful when you already know the user’s organization.
* Node.js
Express.js
```javascript
1
const orgId = getOrganizationFromRequest(req)
2
const redirectUri = 'https://your-app.com/auth/callback'
3
const options = {
4
scopes: ['openid', 'profile', 'email', 'offline_access'],
5
organizationId: orgId,
6
}
7
const url = scalekit.getAuthorizationUrl(redirectUri, options)
8
return res.redirect(url)
```
* Python
Flask
```python
1
from scalekit import AuthorizationUrlOptions
2
3
org_id = get_org_from_request(request)
4
redirect_uri = 'https://your-app.com/auth/callback'
5
options = AuthorizationUrlOptions()
6
options.scopes = ['openid', 'profile', 'email', 'offline_access']
7
options.organization_id = org_id
8
url = scalekit_client.get_authorization_url(redirect_uri, options)
9
return redirect(url)
```
* Go
Gin
```go
1
orgID := getOrgFromRequest(c)
2
redirectUri := "https://your-app.com/auth/callback"
3
options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"}, OrganizationId: orgID}
4
url, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)
5
c.Redirect(http.StatusFound, url.String())
```
* Java
Spring
```java
1
String orgId = getOrgFromRequest(request);
2
String redirectUri = "https://your-app.com/auth/callback";
3
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
4
options.setScopes(Arrays.asList("openid","profile","email","offline_access"));
5
options.setOrganizationId(orgId);
6
URL url = scalekitClient.authentication().getAuthorizationUrl(redirectUri, options);
7
return new RedirectView(url.toString());
```
How do I route users based on email domain?
If you don’t know the organization ID beforehand, you can use `loginHint` to let Scalekit determine the correct authentication method from the user’s email domain. This is common for enterprise logins where the email domain is associated with a specific SSO connection. The domain must be registered to the organization either manually from the Scalekit Dashboard or through the admin portal when [onboarding an enterprise customer](/sso/guides/onboard-enterprise-customers/).
* Node.js
Express.js
```javascript
1
const redirectUri = 'https://your-app.com/auth/callback'
2
const options = {
3
scopes: ['openid', 'profile', 'email', 'offline_access'],
4
loginHint: userEmail
5
}
6
const url = scalekit.getAuthorizationUrl(redirectUri, options)
7
return res.redirect(url)
```
* Python
Flask
```python
1
redirect_uri = 'https://your-app.com/auth/callback'
2
options = AuthorizationUrlOptions()
3
options.scopes = ['openid', 'profile', 'email', 'offline_access']
4
options.login_hint = user_email
5
url = scalekit_client.get_authorization_url(redirect_uri, options)
6
return redirect(url)
```
* Go
Gin
```go
1
redirectUri := "https://your-app.com/auth/callback"
2
options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"}, LoginHint: userEmail}
3
url, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)
4
c.Redirect(http.StatusFound, url.String())
```
* Java
Spring
```java
1
String redirectUri = "https://your-app.com/auth/callback";
2
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
3
options.setScopes(Arrays.asList("openid","profile","email","offline_access"));
4
options.setLoginHint(userEmail);
5
URL url = scalekitClient.authentication().getAuthorizationUrl(redirectUri, options);
6
return new RedirectView(url.toString());
```
How do I route users to a specific SSO connection?
When you know the exact enterprise connection a user should use, you can pass its `connectionId` for the highest routing precision. This bypasses any other routing logic.
* Node.js
Express.js
```javascript
1
const redirectUri = 'https://your-app.com/auth/callback'
2
const options = {
3
scopes: ['openid', 'profile', 'email', 'offline_access'],
4
connectionId: 'conn_123...'
5
}
6
const url = scalekit.getAuthorizationUrl(redirectUri, options)
7
return res.redirect(url)
```
* Python
Flask
```python
1
redirect_uri = 'https://your-app.com/auth/callback'
2
options = AuthorizationUrlOptions()
3
options.scopes = ['openid', 'profile', 'email', 'offline_access']
4
options.connection_id = 'conn_123...'
5
url = scalekit_client.get_authorization_url(redirect_uri, options)
6
return redirect(url)
```
* Go
Gin
```go
1
redirectUri := "https://your-app.com/auth/callback"
2
options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"}, ConnectionId: "conn_123..."}
3
url, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)
4
c.Redirect(http.StatusFound, url.String())
```
* Java
Spring
```java
1
String redirectUri = "https://your-app.com/auth/callback";
2
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
3
options.setScopes(Arrays.asList("openid","profile","email","offline_access"));
4
options.setConnectionId("conn_123...");
5
URL url = scalekitClient.authentication().getAuthorizationUrl(redirectUri, options);
6
return new RedirectView(url.toString());
```
How do I force users to re-authenticate?
You can require users to authenticate again, even if they have an active session, by setting `prompt: 'login'`. This is useful for high-security actions that require recent authentication.
* Node.js
Express.js
```javascript
1
const redirectUri = 'https://your-app.com/auth/callback'
2
const options = {
3
scopes: ['openid', 'profile', 'email', 'offline_access'],
4
prompt: 'login'
5
}
6
return res.redirect(scalekit.getAuthorizationUrl(redirectUri, options))
```
* Python
Flask
```python
1
redirect_uri = 'https://your-app.com/auth/callback'
2
options = AuthorizationUrlOptions()
3
options.scopes = ['openid', 'profile', 'email', 'offline_access']
4
options.prompt = 'login'
5
return redirect(scalekit_client.get_authorization_url(redirect_uri, options))
```
* Go
Gin
```go
1
redirectUri := "https://your-app.com/auth/callback"
2
options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"}, Prompt: "login"}
3
url, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)
4
c.Redirect(http.StatusFound, url.String())
```
* Java
Spring
```java
1
String redirectUri = "https://your-app.com/auth/callback";
2
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
3
options.setScopes(Arrays.asList("openid","profile","email","offline_access"));
4
options.setPrompt("login");
5
return new RedirectView(scalekitClient.authentication().getAuthorizationUrl(redirectUri, options).toString());
```
How do I let users choose an account or organization?
To show the organization or account chooser, set `prompt: 'select_account'`. This is helpful when a user is part of multiple organizations and needs to select which one to sign into.
* Node.js
Express.js
```javascript
1
const redirectUri = 'https://your-app.com/auth/callback'
2
const options = {
3
scopes: ['openid', 'profile', 'email', 'offline_access'],
4
prompt: 'select_account'
5
}
6
return res.redirect(scalekit.getAuthorizationUrl(redirectUri, options))
```
* Python
Flask
```python
1
redirect_uri = 'https://your-app.com/auth/callback'
2
options = AuthorizationUrlOptions()
3
options.scopes = ['openid', 'profile', 'email', 'offline_access']
4
options.prompt = 'select_account'
5
return redirect(scalekit_client.get_authorization_url(redirect_uri, options))
```
* Go
Gin
```go
1
redirectUri := "https://your-app.com/auth/callback"
2
options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"}, Prompt: "select_account"}
3
url, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)
4
c.Redirect(http.StatusFound, url.String())
```
* Java
Spring
```java
1
String redirectUri = "https://your-app.com/auth/callback";
2
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
3
options.setScopes(Arrays.asList("openid","profile","email","offline_access"));
4
options.setPrompt("select_account");
5
return new RedirectView(scalekitClient.authentication().getAuthorizationUrl(redirectUri, options).toString());
```
How do I send users directly to signup?
To send users directly to the signup form instead of the login page, use `prompt: 'create'`.
* Node.js
Express.js
```javascript
1
const redirectUri = 'https://your-app.com/auth/callback'
2
const options = {
3
scopes: ['openid', 'profile', 'email', 'offline_access'],
4
prompt: 'create'
5
}
6
return res.redirect(scalekit.getAuthorizationUrl(redirectUri, options))
```
* Python
Flask
```python
1
redirect_uri = 'https://your-app.com/auth/callback'
2
options = AuthorizationUrlOptions()
3
options.scopes = ['openid', 'profile', 'email', 'offline_access']
4
options.prompt = 'create'
5
return redirect(scalekit_client.get_authorization_url(redirect_uri, options))
```
* Go
Gin
```go
1
redirectUri := "https://your-app.com/auth/callback"
2
options := scalekitClient.AuthorizationUrlOptions{Scopes: []string{"openid","profile","email","offline_access"}, Prompt: "create"}
3
url, _ := scalekitClient.GetAuthorizationUrl(redirectUri, options)
4
c.Redirect(http.StatusFound, url.String())
```
* Java
Spring
```java
1
String redirectUri = "https://your-app.com/auth/callback";
2
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
3
options.setScopes(Arrays.asList("openid","profile","email","offline_access"));
4
options.setPrompt("create");
5
return new RedirectView(scalekitClient.authentication().getAuthorizationUrl(redirectUri, options).toString());
```
How do I redirect users back to the page they requested after authentication?
When users bookmark specific pages or their session expires, redirect them to their original destination after authentication. Store the intended path in a secure cookie before redirecting to Scalekit, then read it after the callback.
**Step 1: Capture the intended destination**
Before redirecting to Scalekit, store the user’s requested path in a secure cookie:
* Node.js
Express.js
```javascript
1
app.get('/login', (req, res) => {
2
const nextPath = typeof req.query.next === 'string' ? req.query.next : '/'
3
// Only allow internal paths to prevent open redirects
4
const safe = nextPath.startsWith('/') && !nextPath.startsWith('//') ? nextPath : '/'
5
res.cookie('sk_return_to', safe, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' })
6
// Build authorization URL and redirect to Scalekit
7
})
```
* Python
Flask
```python
1
@app.route('/login')
2
def login():
3
next_path = request.args.get('next', '/')
4
safe = next_path if next_path.startswith('/') and not next_path.startswith('//') else '/'
5
resp = make_response()
6
resp.set_cookie('sk_return_to', safe, httponly=True, secure=True, samesite='Lax', path='/')
7
return resp
```
* Go
Gin
```go
1
func login(c *gin.Context) {
2
nextPath := c.Query("next")
3
if nextPath == "" || !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
4
nextPath = "/"
5
}
6
cookie := &http.Cookie{Name: "sk_return_to", Value: nextPath, HttpOnly: true, Secure: true, Path: "/"}
7
http.SetCookie(c.Writer, cookie)
8
}
```
* Java
Spring
```java
1
@GetMapping("/login")
2
public void login(HttpServletRequest request, HttpServletResponse response) {
3
String nextPath = Optional.ofNullable(request.getParameter("next")).orElse("/");
4
boolean safe = nextPath.startsWith("/") && !nextPath.startsWith("//");
5
Cookie cookie = new Cookie("sk_return_to", safe ? nextPath : "/");
6
cookie.setHttpOnly(true); cookie.setSecure(true); cookie.setPath("/");
7
response.addCookie(cookie);
8
}
```
**Step 2: Redirect after callback**
After exchanging the authorization code, read the cookie and redirect to the stored path:
* Node.js
Express.js
```javascript
1
app.get('/auth/callback', async (req, res) => {
2
// ... exchange code ...
3
const raw = req.cookies.sk_return_to || '/'
4
const safe = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'
5
res.clearCookie('sk_return_to', { path: '/' })
6
res.redirect(safe || '/dashboard')
7
})
```
* Python
Flask
```python
1
def callback():
2
# ... exchange code ...
3
raw = request.cookies.get('sk_return_to', '/')
4
safe = raw if raw.startswith('/') and not raw.startswith('//') else '/'
5
resp = redirect(safe or '/dashboard')
6
resp.delete_cookie('sk_return_to', path='/')
7
return resp
```
* Go
Gin
```go
1
func callback(c *gin.Context) {
2
// ... exchange code ...
3
raw, _ := c.Cookie("sk_return_to")
4
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
5
raw = "/"
6
}
7
http.SetCookie(c.Writer, &http.Cookie{Name: "sk_return_to", Value: "", MaxAge: -1, Path: "/"})
8
c.Redirect(http.StatusFound, raw)
9
}
```
* Java
Spring
```java
1
public RedirectView callback(HttpServletRequest request, HttpServletResponse response) {
2
// ... exchange code ...
3
String raw = getCookie(request, "sk_return_to").orElse("/");
4
boolean ok = raw.startsWith("/") && !raw.startsWith("//");
5
Cookie clear = new Cookie("sk_return_to", ""); clear.setPath("/"); clear.setMaxAge(0);
6
response.addCookie(clear);
7
return new RedirectView(ok ? raw : "/dashboard");
8
}
```
Never redirect to external origins
Allow only same-origin paths (e.g., `/billing`). Do not accept absolute URLs or protocol-relative URLs. This blocks open redirect attacks.
How do I configure access token lifetime?
Access tokens have a default expiration time, but you can adjust this based on your security requirements. Shorter lifetimes provide better security by limiting the window of exposure if a token is compromised, while longer lifetimes reduce the frequency of token refresh operations.
To configure the access token lifetime:
1. Navigate to the **Scalekit Dashboard**
2. Go to **Authentication** > **Session Policy**
3. Adjust the **Access Token Lifetime** setting to your preferred duration
The `expiresIn` value in the authentication response reflects this configured lifetime in seconds. When the access token expires, use the refresh token to obtain a new access token without requiring the user to re-authenticate.
What is the routing precedence for login?
Scalekit applies connection selection in this order: `connectionId` (or `connection_id`) → `organizationId` → `loginHint` (domain extraction). Prefer the highest confidence signal you have.
Why should I always send a state parameter?
Include a cryptographically strong `state` parameter and validate it on callback to prevent CSRF and session fixation attacks. See [our CSRF protection guide](/guides/security/authentication-best-practices/) for details.
---
# DOCUMENT BOUNDARY
---
# Initiate user signup or login
> Create authorization URLs and redirect users to Scalekit's hosted login page
Login initiation begins your authentication flow. You redirect users to Scalekit’s hosted login page by creating an authorization URL with appropriate parameters.When users visit this URL, Scalekit’s authorization server validates the request, displays the login interface, and handles authentication through your configured connection methods (SSO, social providers, Magic Link or Email OTP
Authorization URL format
```sh
/oauth/authorize?
response_type=code& # always `code` for authorization code flow
client_id=& # Dashboard > Developers > Settings > API Credentials
redirect_uri=& # Dashboard > Authentication > Redirect URLs > Allowed Callback URLs
scope=openid+profile+email+offline_access& # Permissions requested. Include `offline_access` for refresh tokens
state= # prevent CSRF attacks
```
The authorization request includes several parameters that control authentication behavior:
* **Required parameters** ensure Scalekit can identify your application and return the user securely
* **Optional parameters** enable organization routing and pre-populate fields
* **Security parameters** prevent unauthorized access attempts
Understand each parameter and how it controls the authorization flow:
| Query parameter | Description |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `response_type` | Set to `code` for authorization code flow Required Indicates the expected response type |
| `client_id` | Your application’s public identifier from the dashboard Required Scalekit uses this to identify and validate your application |
| `redirect_uri` | Your application’s callback URL where Scalekit returns the authorization code Required Must be registered in your dashboard settings |
| `scope` | Space-separated list of permissions Required Always include `openid profile email`. Add `offline_access` to request refresh tokens for extended sessions |
| `state` | Random string generated by your application Recommended Scalekit returns this unchanged. Use it to prevent CSRF attacks and maintain request state |
| `prompt` | Value to control the authentication flow Optional Use `login` to force re-authentication even when the user has a valid session. Send it only when you need to re-verify identity — including it on routine logins re-prompts users unnecessarily, a common cause of users appearing logged out Use `create` to trigger the sign-up page Use `select_account` to force the organization selector, even if the user has an active session with an organization. The selector appears only when the user belongs to multiple organizations Use `none` to check silently whether a session exists, without showing the login or sign-up page |
| `organization_id` | Skip routing the user to the hosted login page and route them to the enterprise SSO connection configured for the organization Optional |
| `connection_id` | Skip routing the user to the hosted login page and route them to a specific enterprise SSO connection Optional |
| `login_hint` | Used for [Home Realm Discovery](/authenticate/auth-methods/enterprise-sso/#identify-and-enforce-sso-for-organization-users). Scalekit extracts the email domain from `login_hint` and routes the user to the matching organization’s SSO connection based on configured domain rules Optional |
| `provider` | Skip routing user to hosted login page and direct user to a specific social connection. Supported values: `google`, `microsoft`, `github`, `gitlab`, `linkedin`, and `salesforce` Optional |
## Set up login flow
[Section titled “Set up login flow”](#set-up-login-flow)
1. #### Add `state` parameter recommended
[Section titled “Add state parameter ”](#add-state-parameter-)
Always generate a cryptographically secure random string for the `state` parameter and store it temporarily (session, local storage, cache, etc).
This can be used to validate that the state value returned in the callback matches the original value you sent. This prevents **CSRF (Cross-Site Request Forgery)** attacks where an attacker tricks users into approving unauthorized authentication requests.
* Node.js
Generate and store state
```javascript
1
// Generate secure random state
2
const state = require('crypto').randomBytes(32).toString('hex');
3
// Store it temporarily (session, local storage, cache, etc)
4
sessionStorage.oauthState = state;
```
* Python
Generate and store state
```python
1
import os
2
import secrets
3
4
# Generate secure random state
5
state = secrets.token_hex(32)
6
# Store it temporarily (session, local storage, cache, etc)
7
session['oauth_state'] = state
```
* Go
Generate and store state
```go
1
import (
2
"crypto/rand"
3
"encoding/hex"
4
)
5
6
// Generate secure random state
7
b := make([]byte, 32)
8
rand.Read(b)
9
state := hex.EncodeToString(b)
10
// Store it temporarily (session, local storage, cache, etc)
11
// Example for Go: use a storage library
12
// session.Set("oauth_state", state)
```
* Java
Generate and store state
```java
1
import java.security.SecureRandom;
2
import java.util.Base64;
3
4
// Generate secure random state
5
SecureRandom sr = new SecureRandom();
6
byte[] randomBytes = new byte[32];
7
sr.nextBytes(randomBytes);
8
String state = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
9
// Store it temporarily (session, local storage, cache, etc)
10
// Example for Java: use any storage library
11
// session.setAttribute("oauth_state", state);
```
2. #### Redirect to the authorization URL
[Section titled “Redirect to the authorization URL”](#redirect-to-the-authorization-url)
Use the Scalekit SDK to generate the authorization URL. This method constructs the URL locally without making network requests. Redirect users to this URL to start authentication.
* Node.js
Express.js
```diff
4 collapsed lines
1
import { Scalekit } from '@scalekit-sdk/node';
2
3
const scalekit = new Scalekit(/* your credentials */);
4
5
// Basic authorization URL for general login
6
const redirectUri = 'https://yourapp.com/auth/callback';
7
const options = {
8
scopes: ['openid', 'profile', 'email', 'offline_access'],
9
state: sessionStorage.oauthState,
10
};
11
12
const authorizationUrl = scalekit.getAuthorizationUrl(redirectUri, options);
13
14
// Redirect user to Scalekit's hosted login page
15
res.redirect(authorizationUrl);
```
* Python
Flask
```python
3 collapsed lines
1
from scalekit import ScalekitClient, AuthorizationUrlOptions
2
3
scalekit = ScalekitClient(/* your credentials */)
4
5
# Basic authorization URL for general login
6
redirect_uri = 'https://yourapp.com/auth/callback'
7
options = AuthorizationUrlOptions()
8
options.scopes = ['openid', 'profile', 'email', 'offline_access']
9
options.state = session['oauth_state']
10
11
authorization_url = scalekit.get_authorization_url(redirect_uri, options)
12
13
# Redirect user to Scalekit's hosted login page
14
return redirect(authorization_url)
```
* Go
Gin
```go
4 collapsed lines
1
import "github.com/scalekit-inc/scalekit-sdk-go"
2
3
scalekit := scalekit.NewScalekitClient(/* your credentials */)
4
5
// Basic authorization URL for general login
6
redirectUri := "https://yourapp.com/auth/callback"
7
options := scalekit.AuthorizationUrlOptions{
8
Scopes: []string{"openid", "profile", "email", "offline_access"},
9
State: "your_generated_state", // Add this line
10
}
11
12
authorizationUrl, err := scalekitClient.GetAuthorizationUrl(redirectUri, options)
13
14
// Redirect user to Scalekit's hosted login page
15
c.Redirect(http.StatusFound, authorizationUrl.String())
```
* Java
Spring
```java
4 collapsed lines
1
import com.scalekit.ScalekitClient;
2
import com.scalekit.internal.http.AuthorizationUrlOptions;
3
4
ScalekitClient scalekit = new ScalekitClient(/* your credentials */);
5
6
// Basic authorization URL for general login
7
String redirectUri = "https://yourapp.com/auth/callback";
8
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
9
options.setScopes(Arrays.asList("openid", "profile", "email", "offline_access"));
10
options.setState("your_generated_state"); // Add this line
11
12
URL authorizationUrl = scalekit.authentication().getAuthorizationUrl(redirectUri, options);
13
14
// Redirect user to Scalekit's hosted login page
15
return new RedirectView(authorizationUrl.toString());
```
Scalekit will try to verify the user’s identity and redirect them to your application’s callback URL. If the user is a new user, Scalekit will automatically create a new user account.
## Dedicated sign-up flow
[Section titled “Dedicated sign-up flow”](#dedicated-sign-up-flow)
To keep sign-up separate from login, use the `prompt: 'create'` parameter to send users straight to the dedicated sign-up page.
* Node.js
Express.js
```diff
1
const redirectUri = 'http://localhost:3000/api/callback';
2
const options = {
3
scopes: ['openid', 'profile', 'email', 'offline_access'],
4
prompt: 'create', // explicitly takes you to sign up flow
5
};
4 collapsed lines
6
7
const authorizationUrl = scalekit.getAuthorizationUrl(redirectUri, options);
8
9
res.redirect(authorizationUrl);
```
* Python
Flask
```diff
1
from scalekit import AuthorizationUrlOptions
2
3
redirect_uri = 'http://localhost:3000/api/callback'
4
options = AuthorizationUrlOptions()
5
options.scopes=['openid', 'profile', 'email', 'offline_access']
6
options.prompt='create' # optional: explicitly takes you to sign up flow
7
4 collapsed lines
8
authorization_url = scalekit.get_authorization_url(redirect_uri, options)
9
10
# For web frameworks like Flask/Django:
11
# return redirect(authorization_url)
```
* Go
Gin
```diff
1
redirectUri := "http://localhost:3000/api/callback"
2
options := scalekit.AuthorizationUrlOptions{
3
Scopes: []string{"openid", "profile", "email", "offline_access"},
4
+Prompt: "create", // explicitly takes you to sign up flow
5
}
6
8 collapsed lines
7
authorizationUrl, err := scalekitClient.GetAuthorizationUrl(redirectUri, options)
8
if err != nil {
9
// handle error appropriately
10
panic(err)
11
}
12
13
// For web frameworks like Gin:
14
// c.Redirect(http.StatusFound, authorizationUrl.String())
```
* Java
Spring
```diff
4 collapsed lines
1
import com.scalekit.internal.http.AuthorizationUrlOptions;
2
import java.net.URL;
3
import java.util.Arrays;
4
5
String redirectUri = "http://localhost:3000/api/callback";
6
AuthorizationUrlOptions options = new AuthorizationUrlOptions();
7
options.setScopes(Arrays.asList("openid", "profile", "email", "offline_access"));
8
+options.setPrompt("create");
9
10
URL authorizationUrl = scalekit.authentication().getAuthorizationUrl(redirectUri, options);
```
After the user authenticates either in signup or login flows:
1. Scalekit generates an authorization code
2. Makes a callback to your registered allowed callback URL
3. Your backend exchanges the code for tokens by making a server-to-server request
This approach keeps sensitive operations server-side and protects your application’s credentials.
Let’s take a look at how to complete the login in the next step.
---
# DOCUMENT BOUNDARY
---
# Production readiness checklist
> A focused checklist for delivering a production-ready authentication system that's secure, reliable, and compliant
Before launching your authentication system to production, you need to ensure that every aspect of your implementation is secure, tested, and ready for real users. This checklist is organized in the order teams typically implement features when going live, starting with defining your requirements and moving through core flows to advanced features.
Use this checklist systematically to verify that your authentication implementation meets production standards. Each section addresses critical aspects of a production-ready authentication system, from security hardening to user experience testing.
## Define your auth surface
[Section titled “Define your auth surface”](#define-your-auth-surface)
Determine which authentication methods and features you need at launch. This prevents enabling features you don’t need and helps focus your testing efforts.
* \[ ] Decide which login methods to enable (email/password, magic links, social logins, passkeys)
* \[ ] Test all enabled authentication methods from initiation to completion
* \[ ] Verify social login integrations with your configured providers (Google, Microsoft, GitHub, etc.)
* \[ ] Test passkey authentication flows (if enabled)
* \[ ] Verify auth method selection UI works correctly
* \[ ] Test fallback scenarios when auth methods fail
* \[ ] Determine if you’re supporting enterprise customers at launch (SSO, SCIM, admin portal)
* \[ ] Configure proper CORS settings (restrict allowed origins to your domains)
## Core authentication flows
[Section titled “Core authentication flows”](#core-authentication-flows)
Verify that your core authentication flows work correctly and handle errors gracefully. These are the essential flows every application needs.
* \[ ] Verify production environment configuration (environment URL, client ID, and client secret match your production environment, not dev or staging)
* \[ ] Enable HTTPS for all authentication endpoints (prevents token interception)
* \[ ] Test login initiation with authorization URL
* \[ ] Validate redirect URLs match your dashboard configuration exactly
* \[ ] Test authentication completion and code exchange
* \[ ] Validate `state` parameter in callbacks to prevent CSRF attacks
* \[ ] Verify session token storage with `httpOnly`, `secure`, and `sameSite` flags as required
* \[ ] Configure token lifetimes appropriate for your security requirements
* \[ ] Test session timeout and automatic token refresh
* \[ ] Verify logout functionality clears sessions completely
* \[ ] Test error handling for expired tokens, invalid codes, and network failures
* \[ ] Test the complete flow end-to-end in a staging environment
## Network and firewall configuration
[Section titled “Network and firewall configuration”](#network-and-firewall-configuration)
If you’re enabling enterprise SSO or SCIM provisioning for your customers, verify network access early to avoid deployment blockers.
* \[ ] Verify customer firewalls allow Scalekit domains
* \[ ] Test authentication from customer’s network environment
* \[ ] Confirm no proxy servers block Scalekit endpoints
**Domains to whitelist for customer VPNs and firewalls**
If your customers deploy Scalekit behind a corporate firewall or VPN, they need to whitelist these Scalekit domains:
| Domain | Purpose |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `.scalekit.com` | Your Scalekit environment URL (admin portal and authentication; replace this with your actual Scalekit environment URL) |
| `cdn.scalekit.com` | Content delivery network for static assets |
| `docs.scalekit.com` | Documentation portal |
| `fonts.googleapis.com` | Font resources |
Replace `.scalekit.com` with your actual Scalekit environment URL from the Scalekit dashboard.
## Enterprise auth
[Section titled “Enterprise auth”](#enterprise-auth)
If you’re supporting enterprise customers, configure SSO, SCIM provisioning, and the admin portal.
### SSO flows
[Section titled “SSO flows”](#sso-flows)
* \[ ] Test SSO integrations with your target identity providers (Okta, Azure AD, Google Workspace)
* \[ ] Configure SSO user attribute mapping (email, name, groups)
* \[ ] Set up admin portal for enterprise customers to configure their SSO
* \[ ] Test both SP-initiated and IdP-initiated SSO flows
* \[ ] Verify SSO error handling for misconfigured connections
* \[ ] Test SSO with different user scenarios (new users, existing users, deactivated users)
* \[ ] Register all organization domains for [JIT provisioning](/authenticate/manage-users-orgs/jit-provisioning/) (enables automatic user creation)
* \[ ] Configure consistent user identifiers across all SSO connections (email, userPrincipalName, etc.)
* \[ ] Set appropriate default roles for JIT-provisioned users based on your security requirements
* \[ ] Enable “Sync user attributes during login” to keep user profiles updated from the identity provider
* \[ ] Monitor JIT activity and regularly review automatically provisioned users for security
* \[ ] Plan for manual invitations for contractors and external users with non-matching domains
### SCIM provisioning
[Section titled “SCIM provisioning”](#scim-provisioning)
* \[ ] Configure webhook endpoints to receive SCIM events
* \[ ] Verify webhook security with signature validation
* \[ ] Test user provisioning flow (create users automatically)
* \[ ] Test user deprovisioning flow (deactivate/delete users automatically)
* \[ ] Test user updates (profile changes, role updates)
* \[ ] Set up group-based role assignment and synchronization
* \[ ] Test error scenarios (duplicate users, invalid data)
### Admin portal
[Section titled “Admin portal”](#admin-portal)
* \[ ] Configure admin portal access for enterprise customers
* \[ ] Test admin portal SSO configuration flows
* \[ ] Verify admin portal user management features
## Customization
[Section titled “Customization”](#customization)
Ensure your authentication experience matches your brand identity and custom requirements.
* \[ ] Brand your login page with your logo, colors, and styling
* \[ ] Customize email templates for sign-up, password reset, and invitations
* \[ ] Configure custom domain for authentication pages (if applicable)
* \[ ] Set up your preferred email provider in **Dashboard > Customization > Emails**
* \[ ] Test email deliverability and check spam folders
* \[ ] Configure custom user attributes (if needed)
* \[ ] Set up auth flow interceptors (if using)
* \[ ] Configure webhooks for auth events (if using)
* \[ ] Verify webhook security with signature validation
* \[ ] Review and rotate API credentials (store in environment variables, never commit to code)
## User and organization management
[Section titled “User and organization management”](#user-and-organization-management)
Configure how users and organizations are managed in your application.
* \[ ] Configure user profile fields you need to collect during sign-up
* \[ ] Set up organization management (workspaces, teams, tenants)
* \[ ] Test organization creation flow
* \[ ] Test adding users to organizations
* \[ ] Test removing users from organizations
* \[ ] Test user invitation flow and email templates
* \[ ] Set allowed email domains for organization sign-ups (if applicable)
* \[ ] Verify organization switching works for users in multiple organizations
* \[ ] Test user and organization deletion flows
* \[ ] Review [user management settings](/authenticate/fsa/user-management-settings) in your dashboard
If you’re implementing role-based access control (RBAC), verify these authorization items:
* \[ ] Define and create roles and permissions
* \[ ] Configure default roles for new users
* \[ ] Test role assignment to users
* \[ ] Test role assignment to organization members
* \[ ] Verify permission checks in application code
* \[ ] Test access control for different role levels
* \[ ] Validate permission enforcement at API endpoints
## MCP authentication
[Section titled “MCP authentication”](#mcp-authentication)
If you’re implementing MCP authentication for AI agents, verify these items.
* \[ ] Test MCP server authentication flow
* \[ ] Verify OAuth consent screen for MCP clients
* \[ ] Test token exchange for MCP connections
* \[ ] Verify custom auth handlers (if using)
* \[ ] Test MCP session management
* \[ ] Review [MCP troubleshooting](/authenticate/mcp/troubleshooting/) documentation
## Monitoring, logs, and incident readiness
[Section titled “Monitoring, logs, and incident readiness”](#monitoring-logs-and-incident-readiness)
Set up monitoring to track authentication activity and troubleshoot issues quickly.
* \[ ] Set up authentication logs monitoring in **Dashboard > Auth Logs**
* \[ ] Configure alerts for suspicious activity (multiple failed login attempts, unusual locations)
* \[ ] Set up webhook event monitoring and logging
* \[ ] Create dashboards for key metrics (sign-ups, logins, failures, session durations)
* \[ ] Set up error tracking for authentication failures
* \[ ] Configure log retention policies
* \[ ] Test webhook delivery and retry mechanisms
* \[ ] Review [auth logs](/guides/dashboard/auth-logs) documentation
* \[ ] Configure [webhook best practices](/guides/webhooks-best-practices) for reliable event handling
---
# DOCUMENT BOUNDARY
---
# 404
> Wrong endpoint, right universe. Let's get you back on track.
Something broken on our end? Check the [Status page](https://scalekit.statuspage.io/).
---
# DOCUMENT BOUNDARY
---
# Bring your own credentials
> Configure your own OAuth app credentials so users see your brand on consent screens, not Scalekit's.
By default, Scalekit uses its own OAuth app credentials when your users go through the OAuth consent flow. This works for development and testing, but in production your users will see Scalekit’s name and branding on the consent screen, not yours.
**Bring your own credentials** lets you replace Scalekit’s shared OAuth credentials with your own. Once configured, users see your app name, logo, and terms on every OAuth consent screen.
## What changes when you use your own credentials
[Section titled “What changes when you use your own credentials”](#what-changes-when-you-use-your-own-credentials)
* **Consent screens** display your application’s name and branding
* **Rate limits and quotas** are tied to your OAuth app, not Scalekit’s shared pool
* **Provider relationship** is direct, and your OAuth app appears in provider dashboards and audit logs
* **Compliance**: useful if your organization requires a direct relationship with each OAuth provider
Nothing changes in your code or the Scalekit SDK. The switch is purely a dashboard configuration on the connection.
## Configure your credentials
[Section titled “Configure your credentials”](#configure-your-credentials)
1. ### Copy the redirect URI from Scalekit
[Section titled “Copy the redirect URI from Scalekit”](#copy-the-redirect-uri-from-scalekit)
Go to **AgentKit** > **Connections** and click **Edit** on the connection you want to update. Select **Use your own credentials**. The form expands and displays a **Redirect URI**. Copy it.
2. ### Register your OAuth app with the provider
[Section titled “Register your OAuth app with the provider”](#register-your-oauth-app-with-the-provider)
In the provider’s developer console, create a new OAuth app (or use an existing one). Add the Redirect URI you copied in the previous step to the list of authorized redirect URIs.
Redirect URI must match exactly
The URI must match character-for-character. A mismatch will cause OAuth flows to fail with a redirect\_uri\_mismatch error.
The provider gives you a **Client ID** and **Client Secret** after registration.
Many provider consoles create the app in an internal or development mode by default. That works for your own testing but is not sufficient for customer consent.
3. ### Enter your credentials and save
[Section titled “Enter your credentials and save”](#enter-your-credentials-and-save)
Back in Scalekit Dashboard, enter the **Client ID** and **Client Secret** from your OAuth app and click **Save**.
All new OAuth flows for this connection will now use your credentials.
Saving credentials only wires your app into Scalekit. Before customers can connect, promote the app to allow external accounts and validate it end-to-end — see the [AgentKit launch checklist](/agentkit/advanced/launch-checklist/).
## Existing connected accounts
[Section titled “Existing connected accounts”](#existing-connected-accounts)
Existing connected accounts are not affected immediately
Switching credentials does not re-authorize users who are already active. They continue using the previous credentials until they re-authorize. If you need all users to see your branding immediately, generate new authorization links and prompt them to re-authorize.
---
# DOCUMENT BOUNDARY
---
# Set up a custom domain
> Replace the default Scalekit endpoint with your own branded domain using CNAME configuration.
Custom domains enable you to offer a fully branded experience. By default, Scalekit assigns a unique endpoint URL, but you can replace it via CNAME configuration. The custom domain also applies to the authorization server URL shown on the OAuth consent screen during MCP authentication; users will see your branded domain instead of the auto-generated `yourapp-xxxx.scalekit.com`.
| Before | After |
| ------------------------------ | -------------------------- |
| `https://yourapp.scalekit.com` | `https://auth.yourapp.com` |
* **Environment:** CNAME configuration is available only for production environments
* **SSL:** After successful CNAME configuration, an SSL certificate for your custom domain is automatically provisioned
## Set up your custom domain
[Section titled “Set up your custom domain”](#set-up-your-custom-domain)

To set up your custom domain:
1. Go to your domain’s DNS registrar
2. Add a new record to your DNS settings and select **CNAME** as the record type
3. Switch to production environment in the Scalekit dashboard
4. Copy the **Name** (your desired subdomain) from the Scalekit dashboard > Settings > Custom domains and paste it into the **Name/Label/Host** field in your DNS registrar
5. Copy the **Value** from the Scalekit dashboard > Settings > Custom domains and paste it into the **Destination/Target/Value** field in your DNS registrar
6. Save the record in your DNS registrar
7. In the Scalekit dashboard, click **Verify**
CNAME record changes can take up to 72 hours to propagate, although they typically happen much sooner.
## Troubleshoot CNAME verification
[Section titled “Troubleshoot CNAME verification”](#troubleshoot-cname-verification)
If there are any issues during the CNAME verification step:
* Double-check your DNS configuration to ensure all values are correctly entered
* Once the CNAME changes take effect, Scalekit will automatically provision an SSL certificate for your custom domain. This process can take up to 24 hours
You can click on the **Check** button in the Scalekit dashboard to verify SSL certification status. If SSL provisioning takes longer than 24 hours, please contact us at [](mailto:support@scalekit.com)
## DNS registrar guides
[Section titled “DNS registrar guides”](#dns-registrar-guides)
For detailed instructions on adding a CNAME record in specific registrars:
* [GoDaddy: Add a CNAME record](https://www.godaddy.com/en-in/help/add-a-cname-record-19236)
* [Namecheap: How to create a CNAME record](https://www.namecheap.com/support/knowledgebase/article.aspx/9646/2237/how-to-create-a-cname-record-for-your-domain)
---
# DOCUMENT BOUNDARY
---
# AgentKit launch checklist
> Verify your AgentKit integration is production-ready before going live.
Use this checklist before moving your AgentKit integration to production.
## Environment and credentials
[Section titled “Environment and credentials”](#environment-and-credentials)
* \[ ] Switch to the production environment in the Scalekit dashboard
* \[ ] Set `SCALEKIT_ENV_URL`, `SCALEKIT_CLIENT_ID`, and `SCALEKIT_CLIENT_SECRET` to production values, not dev or staging
## Connections
[Section titled “Connections”](#connections)
* \[ ] All connectors your agent uses are configured in the production environment
* \[ ] Each connection shows as active in the dashboard
* \[ ] Connection names used in code match the names in the dashboard exactly
## Connector OAuth apps (if you registered your own app)
[Section titled “Connector OAuth apps (if you registered your own app)”](#connector-oauth-apps-if-you-registered-your-own-app)
Complete this section for any connector where you registered the OAuth app yourself in the provider’s console. Providers create new OAuth apps in a development or test mode that authorizes only the account that created the app. The connection works while you build and test with your own account, then fails for customer tenants in production — for example, Airtable returns “OAuth app can’t be used outside development,” and ZoomInfo requires a partner application rather than a custom (internal) app for cross-account access.
* \[ ] Provider OAuth app is promoted out of development or test mode (published, production, or partner — depending on the provider) so accounts outside your own can authorize
* \[ ] Provider app profile is complete where the provider requires it before publishing (logo, terms of service, privacy policy — for example, Airtable Builder Hub)
* \[ ] After promoting the app, `client_id`, `client_secret`, redirect URI, and scopes still match between the provider and Scalekit (promotion can rotate the `client_id`)
* \[ ] Authorization tested with an account outside the workspace that created the app, not only your own test account
## Authorization and connected accounts
[Section titled “Authorization and connected accounts”](#authorization-and-connected-accounts)
* \[ ] End-to-end authorization flow tested with a real user account in production
* \[ ] Connected accounts created and verified for at least one test user
* \[ ] Magic link generation and redirect tested (OAuth connectors)
* \[ ] Re-authorization flow tested: verify behavior when a token expires or is revoked
## Security
[Section titled “Security”](#security)
* \[ ] MCP URLs are generated and consumed server-side only; never passed to or generated in client-side code
* \[ ] `identifier` values passed to Tool Proxy are tied to authenticated users, not shared, static, or guessable
* \[ ] Session tokens are minted fresh before each agent run and not reused across sessions
## Custom connector (if applicable)
[Section titled “Custom connector (if applicable)”](#custom-connector-if-applicable)
* \[ ] Connector definition promoted from Dev to Production (see [Create your own connector](/agentkit/bring-your-own-connector/create-connector))
* \[ ] Auth pattern validated with a real connected account in production
* \[ ] Tool Proxy calls return expected responses against the production upstream API
## Go live
[Section titled “Go live”](#go-live)
* \[ ] Custom domain configured and SSL verified (see [Custom domain](/agentkit/advanced/custom-domain))
---
# DOCUMENT BOUNDARY
---
# Migrate from Composio to Scalekit
> Map Composio concepts to Scalekit AgentKit equivalents and update your agent code step by step.
This guide maps Composio concepts to their Scalekit AgentKit equivalents and walks through each migration step: SDK setup, authentication, tool execution, and MCP. Use it as a reference while porting your agent code.
Users must re-authorize
OAuth refresh tokens are bound to the OAuth client that issued them. After migration, each user must complete the OAuth consent flow once through Scalekit to create a new connected account. Existing Composio tokens cannot be transferred.
## Concept mapping
[Section titled “Concept mapping”](#concept-mapping)
| Composio | Scalekit | Notes |
| ------------------------------------ | ------------------------------------------ | --------------------------------------------------------------- |
| Toolkit (e.g. `GITHUB`) | **Connector** (e.g. `github`) | Scalekit uses lowercase slugs |
| Tool (e.g. `GITHUB_CREATE_ISSUE`) | **Tool** (e.g. `github_create_issue`) | Same concept, lowercase naming |
| Auth config | **Connection** | OAuth app credentials, scopes, redirect URIs |
| Connected account | **Connected account** | Per-user credential record |
| `user_id` / entity ID | **`identifier`** | Your app’s unique user ID, passed per API call |
| Connect Link | **Authorization link** | OAuth redirect URL for user consent |
| Session (`composio.create()`) | No equivalent | Scalekit is stateless — pass `identifier` per call |
| Provider package (`composio_openai`) | No equivalent | Scalekit uses a single SDK for all frameworks |
| `session.tools()` | `listScopedTools()` | Get tools a user is authorized to call |
| `session.tools.execute()` | `executeTool()` | Execute a tool on behalf of a user |
| `session.mcp.url` | **Virtual MCP Server URL + session token** | Static server URL with a short-lived bearer token per agent run |
| Custom tool (in-memory) | **Custom tool** (API Proxy) | Defined in your app code using `actions.request()` |
| `executeToolRequest` (proxy) | `actions.request()` | Proxied REST API call |
| Trigger | No equivalent | Scalekit does not support event-driven triggers |
| `COMPOSIO_SEARCH_TOOLS` | No equivalent | Use `listScopedTools` with connection name filters |
| `COMPOSIO_REMOTE_WORKBENCH` | No equivalent | No remote sandbox execution |
## 1. Set up Scalekit
[Section titled “1. Set up Scalekit”](#1-set-up-scalekit)
1. **Create a Scalekit account**
Sign up at [app.scalekit.com](https://app.scalekit.com) and copy your API credentials from **Dashboard > Developers > Settings > API Credentials**.
2. **Set environment variables**
```bash
1
SCALEKIT_CLIENT_ID=your_client_id
2
SCALEKIT_CLIENT_SECRET=your_client_secret
3
SCALEKIT_ENV_URL=https://your-env.scalekit.com
```
3. **Install the SDK**
* Python
```bash
1
pip install scalekit-sdk-python
```
* Node.js
```bash
1
npm install @scalekit-sdk/node
```
4. **Initialize the client**
Scalekit uses a single client instance. There is no session object — you pass `identifier` on each API call.
* Python
```python
1
import os
2
import scalekit.client
3
4
scalekit_client = scalekit.client.ScalekitClient(
5
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
6
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
7
env_url=os.getenv("SCALEKIT_ENV_URL"),
8
)
9
actions = scalekit_client.actions
```
* Node.js
```typescript
1
import { ScalekitClient } from '@scalekit-sdk/node';
2
3
const scalekit = new ScalekitClient(
4
process.env.SCALEKIT_ENV_URL!,
5
process.env.SCALEKIT_CLIENT_ID!,
6
process.env.SCALEKIT_CLIENT_SECRET!
7
);
```
## 2. Configure connections
[Section titled “2. Configure connections”](#2-configure-connections)
In Composio, auth configs are created programmatically or via the dashboard. In Scalekit, you configure **connections** in the dashboard.
For each Composio toolkit your agent uses (Gmail, Slack, GitHub, etc.), create a corresponding connection in **Dashboard > AgentKit > Connections > Add connection**. See [Configure a connection](/agentkit/connections/) for the full walkthrough.
| Composio auth type | Scalekit equivalent |
| ---------------------------- | ------------------------------------------------------------------ |
| OAuth 2.0 (Composio managed) | OAuth 2.0 (use Scalekit credentials to start, then bring your own) |
| OAuth 2.0 (custom) | OAuth 2.0 (bring your own credentials) |
| API key | API key (user provides during connected account creation) |
| Bearer token | Bearer token |
| Basic auth | Basic auth |
Scalekit credentials for quick testing
Some connectors offer a **Use Scalekit credentials** option that lets you skip OAuth app registration during development. Switch to your own credentials before production. See [Bring your own OAuth](/agentkit/advanced/bring-your-own-oauth/).
## 3. Migrate authentication
[Section titled “3. Migrate authentication”](#3-migrate-authentication)
Both platforms create per-user records (connected accounts) and generate OAuth links. The SDK methods differ.
#### Create a connected account and authorize
[Section titled “Create a connected account and authorize”](#create-a-connected-account-and-authorize)
* Python
**Before (Composio):**
```python
1
# Composio handles auth in-chat or via connect link
2
session = composio.create(user_id="user_123")
3
# Auth is triggered automatically when a tool requires it
```
**After (Scalekit):**
```python
1
# Create or retrieve the connected account
2
response = actions.get_or_create_connected_account(
3
connection_name="gmail",
4
identifier="user_123",
5
)
6
connected_account = response.connected_account
7
8
# Generate an authorization link if the account is not yet active
9
if connected_account.status != "ACTIVE":
10
link_response = actions.get_authorization_link(
11
connection_name="gmail",
12
identifier="user_123",
13
)
14
auth_url = link_response.link
15
# Redirect or send auth_url to the user
```
* Node.js
**Before (Composio):**
```typescript
1
// Composio handles auth in-chat or via connect link
2
const session = await composio.create("user_123");
3
// Auth is triggered automatically when a tool requires it
```
**After (Scalekit):**
```typescript
1
// Create or retrieve the connected account
2
const response = await scalekit.actions.getOrCreateConnectedAccount({
3
connectionName: 'gmail',
4
identifier: 'user_123',
5
});
6
7
const connectedAccount = response.connectedAccount;
8
9
// Generate an authorization link if the account is not yet active
10
if (connectedAccount?.status !== 'ACTIVE') {
11
const linkResponse = await scalekit.actions.getAuthorizationLink({
12
connectionName: 'gmail',
13
identifier: 'user_123',
14
});
15
const authUrl = linkResponse.link;
16
// Redirect or send authUrl to the user
17
}
```
**Key difference:** Composio can trigger auth in-chat automatically. With Scalekit, your app explicitly creates the connected account and sends the authorization link to the user. Once the user completes the OAuth flow, the connected account becomes `ACTIVE` and your agent can execute tools.
#### Check connected account status
[Section titled “Check connected account status”](#check-connected-account-status)
Composio tracks two statuses (`ACTIVE` and `INACTIVE`). Scalekit uses more granular states:
| Scalekit status | Meaning |
| --------------- | -------------------------------------------------------------- |
| `PENDING` | User hasn’t completed authentication |
| `ACTIVE` | Credentials valid, ready for tool calls |
| `EXPIRED` | Credentials expired or invalidated, re-authentication required |
| `REVOKED` | User revoked access or credentials were invalidated |
| `ERROR` | Authentication or configuration error |
Check status before executing tools. If the account is not `ACTIVE`, generate a new authorization link.
## 4. Migrate tool calls
[Section titled “4. Migrate tool calls”](#4-migrate-tool-calls)
#### List available tools
[Section titled “List available tools”](#list-available-tools)
* Python
**Before (Composio):**
```python
1
session = composio.create(user_id="user_123")
2
tools = session.tools() # all tools the user is authorized for
```
**After (Scalekit):**
```python
1
tools_response = scalekit_client.actions.tools.list_scoped_tools(
2
identifier="user_123",
3
filter={"connection_names": ["gmail"]}, # optional; omit for all connectors
4
page_size=100,
5
)
```
* Node.js
**Before (Composio):**
```typescript
1
const session = await composio.create("user_123");
2
const tools = await session.tools(); // all tools the user is authorized for
```
**After (Scalekit):**
```typescript
1
const { tools } = await scalekit.tools.listScopedTools('user_123', {
2
filter: { connectionNames: ['gmail'] }, // optional; omit for all connectors
3
pageSize: 100,
4
});
```
#### Execute a tool
[Section titled “Execute a tool”](#execute-a-tool)
* Python
**Before (Composio):**
```python
1
session = composio.create(user_id="user_123")
2
tools = session.tools()
3
# Framework handles execution via the agent loop, or:
4
# composio.tools.execute(tool_name="GMAIL_FETCH_MAILS", params={...})
```
**After (Scalekit):**
```python
1
result = actions.execute_tool(
2
tool_name="gmail_fetch_mails",
3
identifier="user_123",
4
connection_name="gmail",
5
tool_input={"query": "is:unread", "max_results": 5},
6
)
7
print(result.data)
```
* Node.js
**Before (Composio):**
```typescript
1
const session = await composio.create("user_123");
2
const tools = await session.tools();
3
// Framework handles execution via the agent loop
```
**After (Scalekit):**
```typescript
1
const result = await scalekit.actions.executeTool({
2
toolName: 'gmail_fetch_mails',
3
identifier: 'user_123',
4
connectionName: 'gmail',
5
toolInput: { query: 'is:unread', max_results: 5 },
6
});
7
console.log(result.data);
```
**Key differences:**
* Composio tool names are uppercase (`GMAIL_FETCH_MAILS`); Scalekit uses lowercase (`gmail_fetch_mails`)
* Composio’s session model means you don’t pass `user_id` on each call. With Scalekit, pass `identifier` and `connection_name` on every `executeTool` call
* Both return structured, LLM-ready output
### Map tool names
[Section titled “Map tool names”](#map-tool-names)
Composio and Scalekit may name tools differently for the same connector. Browse the connector’s tool list in the [Scalekit connector catalog](/agentkit/connectors/) to find the exact tool names. Common patterns:
| Composio tool name | Scalekit tool name |
| --------------------- | --------------------- |
| `GMAIL_FETCH_MAILS` | `gmail_fetch_mails` |
| `SLACK_SEND_MESSAGE` | `slack_send_message` |
| `GITHUB_CREATE_ISSUE` | `github_create_issue` |
| `NOTION_CREATE_PAGE` | `notion_create_page` |
Tool input schemas may also differ. Check each tool’s parameters in the connector catalog and update your agent’s tool input accordingly.
## 5. Migrate MCP
[Section titled “5. Migrate MCP”](#5-migrate-mcp)
Both platforms support MCP (Model Context Protocol) for framework-agnostic tool discovery and execution.
**Before (Composio):**
```json
1
{
2
"mcpServers": {
3
"composio": {
4
"url": "https://backend.composio.dev/v3/mcp/{SERVER_ID}?user_id={USER_ID}",
5
"headers": {
6
"x-api-key": ""
7
}
8
}
9
}
10
}
```
**After (Scalekit):**
Scalekit MCP uses Virtual MCP Servers:
1. **Create an MCP config** — define which connections and tools the server exposes (one-time). This gives you a static `mcp_server_url`.
2. **Mint a session token** — before each agent run, call `create_session_token` for the user. Pass it as a bearer auth header.
See [Virtual MCP Servers](/agentkit/mcp/overview/) for the full setup.
```json
1
{
2
"mcpServers": {
3
"scalekit": {
4
"url": "",
5
"headers": {
6
"Authorization": "Bearer "
7
}
8
}
9
}
10
}
```
**Key difference:** Composio embeds the user ID in the URL. Scalekit uses a static server URL shared across all users, with a short-lived session token per agent run for authentication.
## 6. Migrate custom tools
[Section titled “6. Migrate custom tools”](#6-migrate-custom-tools)
In Composio, custom tools are defined in code with decorators and stored in memory — they’re lost on restart. In Scalekit, custom tools use **API Proxy mode** (`actions.request`). The proxy is available out of the box for every connector with no extra configuration. You define the tool contract in your application code and call the provider’s REST endpoint through Scalekit, which injects the user’s credentials automatically.
| Composio approach | Scalekit approach |
| ------------------------------------------------ | -------------------------------------------------------------- |
| `@composio.tools.custom_tool` decorator | Define the tool in your app code |
| In-memory, lost on restart | Lives in your codebase |
| `executeToolRequest` for authenticated API calls | `actions.request()` — works out of the box for every connector |
* Python
```python
1
response = actions.request(
2
connection_name="gmail",
3
identifier="user_123",
4
method="GET",
5
path="/gmail/v1/users/me/messages",
6
)
```
* Node.js
```typescript
1
const response = await scalekit.actions.request({
2
connectionName: 'gmail',
3
identifier: 'user_123',
4
method: 'GET',
5
path: '/gmail/v1/users/me/messages',
6
});
```
## 7. Add custom connectors
[Section titled “7. Add custom connectors”](#7-add-custom-connectors)
If your agent connects to an API or MCP server that isn’t in Scalekit’s built-in catalog, you can add your own connector. Custom connectors support OAuth 2.0, API keys, bearer tokens, and other auth types. Once created, they work exactly like built-in connectors — same connected account flow, same `actions.request()` proxy, same MCP tool calling.
This goes beyond what Composio offers with in-memory custom tools: Scalekit custom connectors are persistent, support any SaaS API, partner system, or internal service, and keep all credential handling centralized.
See [Add your own connector](/agentkit/bring-your-own-connector/overview/) for the full walkthrough.
## Checklist
[Section titled “Checklist”](#checklist)
* \[ ] Scalekit account created, API credentials saved as environment variables
* \[ ] Scalekit SDK installed
* \[ ] Connections created in the Scalekit Dashboard for each connector
* \[ ] Connected account creation and authorization link flow ported
* \[ ] Tool names updated from uppercase to lowercase
* \[ ] `executeTool` calls updated with `identifier` and `connection_name`
* \[ ] Tool input schemas verified against the Scalekit connector catalog
* \[ ] MCP config created and session token flow implemented (if using MCP)
* \[ ] Custom tools ported to `actions.request()` (if applicable)
* \[ ] Agent tested end-to-end with a test user
* \[ ] Users re-authorized through Scalekit’s OAuth flow
---
# DOCUMENT BOUNDARY
---
# Proxy API Calls
> Use Scalekit managed authentication and make direct HTTP calls to third party applications
Even though Scalekit Agent Auth offers pre-built connector tools out of the box for the supported applications, if you would like to make direct API calls to the third party applications for any custom behaviour, you can leverage proxy\_api tool to directly invoke the third party application.
Based on the connected account or user identifier details, Scalekit will automatically inject the user authorization tokens so that API calls to the third application will be successful.
Proxy must be enabled per environment
Proxy access for built-in providers (Gmail, Notion, Slack, and others) is **not enabled by default** on new environments. If you receive the error `proxy not enabled for provider`, contact to enable the proxy for your environment.
```python
1
# Fetch recent emails
2
emails = actions.tools.execute(
3
connected_account_id=connected_account.id,
4
tool='gmail_proxy_api',
5
parameters={
6
'path': '/gmail/v1/users/me/messages',
7
'method': 'GET',
8
'headers': [{'Content-Type': 'application/json'}],
9
'params': [{'max_results': '5'}],
10
'body': '' #actual JSON payload
11
}
12
)
13
14
print(f'Recent emails: {emails.result}')
```
As part of the above execution, Scalekit will automatically inject Bearer token in the request header before making the API call to GMAIL.
## Common scenarios
[Section titled “Common scenarios”](#common-scenarios)
How do I allowlist Scalekit’s outbound IP addresses on a downstream service?
Scalekit makes outbound tool calls and proxied API requests to third-party applications from a fixed set of IP addresses. If a downstream service restricts inbound traffic to an allowlist, add the IP address for your environment’s region so these calls succeed.
| Region | Outbound IP address |
| -------------- | ------------------- |
| United States | `34.94.129.140` |
| European Union | `35.198.115.68` |
All outbound tool calls and proxied requests originate from the IP address that matches your environment’s region.
---
# DOCUMENT BOUNDARY
---
# Authentication Methods Comparison
> Compare different authentication methods supported by AgentKit including OAuth 2.0, API Keys, Bearer Tokens, and Custom JWT to choose the right approach.
AgentKit supports multiple authentication methods to connect with third-party providers. This guide helps you understand the differences and choose the right authentication method for your use case.
## Authentication methods overview
[Section titled “Authentication methods overview”](#authentication-methods-overview)
OAuth 2.0
**Most secure and widely supported**
User-delegated authentication with automatic token refresh and granular permissions.
**Best for:** Google, Microsoft, Slack, GitHub
API Keys
**Simple static credentials**
Provider-issued keys for straightforward server-to-server authentication.
**Best for:** Jira, Asana, Linear, Airtable
Bearer Tokens
**User-generated tokens**
Personal access tokens with scoped permissions for individual use.
**Best for:** GitHub PATs, GitLab tokens
Custom JWT
**Advanced signed tokens**
Cryptographically signed tokens for service accounts and custom protocols.
**Best for:** Custom integrations, service accounts
## Comparison matrix
[Section titled “Comparison matrix”](#comparison-matrix)
| Feature | OAuth 2.0 | API Keys | Bearer Tokens | Custom JWT |
| ------------------------ | ---------- | -------- | ------------- | ------------ |
| **Security Level** | High | Medium | Medium | High |
| **User Interaction** | Required | Optional | Required | Not required |
| **Token Refresh** | Automatic | N/A | Manual | Varies |
| **Setup Complexity** | Moderate | Easy | Easy | Complex |
| **Granular Permissions** | Yes | Limited | Yes | Limited |
| **Provider Support** | Widespread | Common | Common | Limited |
## When to use each method
[Section titled “When to use each method”](#when-to-use-each-method)
### OAuth 2.0
[Section titled “OAuth 2.0”](#oauth-20)
**Use when:**
* Provider supports OAuth
* Acting on behalf of users
* Need automatic token refresh
* Require granular permissions
* Building user-facing applications
**Example:** User connects Gmail to send emails through your app
### API Keys
[Section titled “API Keys”](#api-keys)
**Use when:**
* Provider only supports API keys
* Building internal tools
* Server-to-server communication
* Simplicity is priority
**Example:** Automated Jira ticket creation for support system
### Bearer Tokens
[Section titled “Bearer Tokens”](#bearer-tokens)
**Use when:**
* Personal access is sufficient
* Building developer tools
* OAuth unavailable
* User prefers manual control
**Example:** Personal GitHub repository automation
### Custom JWT
[Section titled “Custom JWT”](#custom-jwt)
**Use when:**
* Provider requires JWT
* Service account access needed
* Custom authentication protocol
* Advanced security requirements
**Example:** Enterprise service account integrations
## Next steps
[Section titled “Next steps”](#next-steps)
* [Connectors](/agentkit/connectors) - Available third-party providers
* [Connections](/agentkit/connections) - Configure provider connections
* [Authorization Methods](/agentkit/tools/authorize) - Detailed authentication implementation
---
# DOCUMENT BOUNDARY
---
# Testing Authentication Flows
> Learn how to test AgentKit authentication flows in development, staging, and production environments with comprehensive testing strategies.
Thorough testing of authentication flows ensures your AgentKit integration works reliably before production deployment. This guide covers testing strategies, tools, and best practices.
## Testing environments
[Section titled “Testing environments”](#testing-environments)
### Development environment
[Section titled “Development environment”](#development-environment)
**Purpose:** Rapid iteration and debugging
**Characteristics:**
* Local development server
* Test accounts and credentials
* Verbose logging enabled
* Quick feedback loops
**Setup:**
development.env
```python
1
SCALEKIT_ENV_URL=https://your-env.scalekit.dev
2
SCALEKIT_CLIENT_ID=dev_client_id
3
SCALEKIT_CLIENT_SECRET=dev_client_secret
4
DEBUG=true
5
LOG_LEVEL=debug
```
### Staging environment
[Section titled “Staging environment”](#staging-environment)
**Purpose:** Pre-production validation
**Characteristics:**
* Production-like configuration
* Realistic data volumes
* Integration with staging third-party accounts
* Performance testing
**Setup:**
staging.env
```python
1
SCALEKIT_ENV_URL=https://your-env.scalekit.cloud
2
SCALEKIT_CLIENT_ID=staging_client_id
3
SCALEKIT_CLIENT_SECRET=staging_client_secret
4
DEBUG=false
5
LOG_LEVEL=info
```
### Production environment
[Section titled “Production environment”](#production-environment)
**Purpose:** Live user traffic
**Characteristics:**
* Real user data
* Verified OAuth applications
* Monitoring and alerts
* Minimal logging
**Setup:**
production.env
```python
1
SCALEKIT_ENV_URL=https://your-env.scalekit.cloud
2
SCALEKIT_CLIENT_ID=prod_client_id
3
SCALEKIT_CLIENT_SECRET=prod_client_secret
4
DEBUG=false
5
LOG_LEVEL=warn
```
## Test account setup
[Section titled “Test account setup”](#test-account-setup)
### Creating test providers
[Section titled “Creating test providers”](#creating-test-providers)
Set up test accounts for each provider:
**Google Workspace:**
1. Create test Google account
2. Enable 2FA if testing MFA scenarios
3. Use for Gmail, Calendar, Drive testing
**Slack:**
1. Create free Slack workspace
2. Install your Slack app
3. Use for messaging and notification testing
**Microsoft 365:**
1. Get Microsoft 365 developer account (free)
2. Create test users
3. Use for Outlook, Teams, OneDrive testing
**Jira/Atlassian:**
1. Create free Atlassian Cloud account
2. Set up test projects
3. Generate API tokens for testing
### Test user patterns
[Section titled “Test user patterns”](#test-user-patterns)
Create different test users for scenarios:
```python
1
# Test user configurations
2
TEST_USERS = {
3
"basic_user": {
4
"identifier": "test_user_001",
5
"providers": ["gmail"],
6
"scenario": "Single provider, basic authentication"
7
},
8
"power_user": {
9
"identifier": "test_user_002",
10
"providers": ["gmail", "slack", "jira", "calendar"],
11
"scenario": "Multiple providers, full feature access"
12
},
13
"expired_user": {
14
"identifier": "test_user_003",
15
"providers": ["gmail"],
16
"scenario": "Expired tokens, test refresh logic",
17
"setup": "Manually expire tokens"
18
},
19
"revoked_user": {
20
"identifier": "test_user_004",
21
"providers": ["slack"],
22
"scenario": "User revoked access, test re-auth flow"
23
}
24
}
```
## Unit testing authentication
[Section titled “Unit testing authentication”](#unit-testing-authentication)
### Test connected account creation
[Section titled “Test connected account creation”](#test-connected-account-creation)
* Python
```python
1
import unittest
2
from unittest.mock import Mock, patch
3
4
class TestConnectedAccountCreation(unittest.TestCase):
5
def setUp(self):
6
self.actions = Mock()
7
self.user_id = "test_user_123"
8
self.provider = "gmail"
9
10
def test_create_connected_account_success(self):
11
"""Test successful connected account creation"""
12
# Mock response
13
mock_response = Mock()
14
mock_response.connected_account = Mock(
15
id="account_123",
16
status="PENDING",
17
connection_name="gmail"
18
)
19
self.actions.get_or_create_connected_account.return_value = mock_response
20
21
# Execute
22
response = self.actions.get_or_create_connected_account(
23
connection_name=self.provider,
24
identifier=self.user_id
25
)
26
27
# Assert
28
self.assertEqual(response.connected_account.status, "PENDING")
29
self.assertEqual(response.connected_account.connection_name, "gmail")
30
31
def test_generate_authorization_link(self):
32
"""Test authorization link generation"""
33
mock_response = Mock()
34
mock_response.link = "https://accounts.google.com/oauth/authorize?..."
35
36
self.actions.get_authorization_link.return_value = mock_response
37
38
response = self.actions.get_authorization_link(
39
connection_name=self.provider,
40
identifier=self.user_id
41
)
42
43
self.assertIn("https://", response.link)
44
self.actions.get_authorization_link.assert_called_once()
45
46
if __name__ == '__main__':
47
unittest.main()
```
* Node.js
```javascript
1
const { describe, it, expect, jest, beforeEach } = require('@jest/globals');
2
3
describe('Connected Account Creation', () => {
4
let mockActions;
5
const userId = 'test_user_123';
6
const provider = 'gmail';
7
8
beforeEach(() => {
9
mockActions = {
10
getOrCreateConnectedAccount: jest.fn(),
11
getAuthorizationLink: jest.fn()
12
};
13
});
14
15
it('should create connected account successfully', async () => {
16
// Mock response
17
const mockResponse = {
18
connectedAccount: {
19
id: 'account_123',
20
status: 'PENDING',
21
connectionName: 'gmail'
22
}
23
};
24
25
mockActions.getOrCreateConnectedAccount.mockResolvedValue(mockResponse);
26
27
// Execute
28
const response = await mockActions.getOrCreateConnectedAccount({
29
connectionName: provider,
30
identifier: userId
31
});
32
33
// Assert
34
expect(response.connectedAccount.status).toBe('PENDING');
35
expect(response.connectedAccount.connectionName).toBe('gmail');
36
});
37
38
it('should generate authorization link', async () => {
39
const mockResponse = {
40
link: 'https://accounts.google.com/oauth/authorize?...'
41
};
42
43
mockActions.getAuthorizationLink.mockResolvedValue(mockResponse);
44
45
const response = await mockActions.getAuthorizationLink({
46
connectionName: provider,
47
identifier: userId
48
});
49
50
expect(response.link).toContain('https://');
51
expect(mockActions.getAuthorizationLink).toHaveBeenCalledTimes(1);
52
});
53
});
```
* Go
```go
1
package auth_test
2
3
import (
4
"testing"
5
"github.com/stretchr/testify/assert"
6
"github.com/stretchr/testify/mock"
7
)
8
9
type MockActions struct {
10
mock.Mock
11
}
12
13
func (m *MockActions) GetOrCreateConnectedAccount(connectionName, identifier string) (*ConnectedAccountResponse, error) {
14
args := m.Called(connectionName, identifier)
15
return args.Get(0).(*ConnectedAccountResponse), args.Error(1)
16
}
17
18
func TestCreateConnectedAccount(t *testing.T) {
19
// Arrange
20
mockActions := new(MockActions)
21
userId := "test_user_123"
22
provider := "gmail"
23
24
expectedResponse := &ConnectedAccountResponse{
25
ConnectedAccount: ConnectedAccount{
26
ID: "account_123",
27
Status: "PENDING",
28
ConnectionName: "gmail",
29
},
30
}
31
32
mockActions.On("GetOrCreateConnectedAccount", provider, userId).
33
Return(expectedResponse, nil)
34
35
// Act
36
response, err := mockActions.GetOrCreateConnectedAccount(provider, userId)
37
38
// Assert
39
assert.NoError(t, err)
40
assert.Equal(t, "PENDING", response.ConnectedAccount.Status)
41
assert.Equal(t, "gmail", response.ConnectedAccount.ConnectionName)
42
mockActions.AssertExpectations(t)
43
}
```
* Java
```java
1
import org.junit.jupiter.api.BeforeEach;
2
import org.junit.jupiter.api.Test;
3
import org.mockito.Mock;
4
import org.mockito.MockitoAnnotations;
5
import static org.junit.jupiter.api.Assertions.*;
6
import static org.mockito.Mockito.*;
7
8
class ConnectedAccountCreationTest {
9
@Mock
10
private Actions mockActions;
11
12
private String userId;
13
private String provider;
14
15
@BeforeEach
16
void setUp() {
17
MockitoAnnotations.openMocks(this);
18
userId = "test_user_123";
19
provider = "gmail";
20
}
21
22
@Test
23
void testCreateConnectedAccountSuccess() {
24
// Arrange
25
ConnectedAccount account = new ConnectedAccount();
26
account.setId("account_123");
27
account.setStatus("PENDING");
28
account.setConnectionName("gmail");
29
30
ConnectedAccountResponse mockResponse = new ConnectedAccountResponse();
31
mockResponse.setConnectedAccount(account);
32
33
when(mockActions.getOrCreateConnectedAccount(provider, userId))
34
.thenReturn(mockResponse);
35
36
// Act
37
ConnectedAccountResponse response = mockActions
38
.getOrCreateConnectedAccount(provider, userId);
39
40
// Assert
41
assertEquals("PENDING", response.getConnectedAccount().getStatus());
42
assertEquals("gmail", response.getConnectedAccount().getConnectionName());
43
verify(mockActions, times(1)).getOrCreateConnectedAccount(provider, userId);
44
}
45
}
```
### Test token refresh logic
[Section titled “Test token refresh logic”](#test-token-refresh-logic)
```python
1
def test_token_refresh_scenarios(self):
2
"""Test various token refresh scenarios"""
3
test_cases = [
4
{
5
"name": "successful_refresh",
6
"initial_status": "EXPIRED",
7
"expected_status": "ACTIVE",
8
"should_succeed": True
9
},
10
{
11
"name": "refresh_token_invalid",
12
"initial_status": "EXPIRED",
13
"expected_status": "EXPIRED",
14
"should_succeed": False
15
},
16
{
17
"name": "already_active",
18
"initial_status": "ACTIVE",
19
"expected_status": "ACTIVE",
20
"should_succeed": True
21
}
22
]
23
24
for case in test_cases:
25
with self.subTest(case=case["name"]):
26
# Setup mock
27
mock_account = Mock()
28
mock_account.status = case["expected_status"]
29
30
if case["should_succeed"]:
31
self.actions.refresh_connected_account.return_value = mock_account
32
else:
33
self.actions.refresh_connected_account.side_effect = Exception("Refresh failed")
34
35
# Execute
36
try:
37
result = self.actions.refresh_connected_account(
38
identifier="test_user",
39
connection_name="gmail"
40
)
41
success = True
42
except Exception:
43
success = False
44
45
# Assert
46
self.assertEqual(success, case["should_succeed"])
```
## Integration testing
[Section titled “Integration testing”](#integration-testing)
### Test complete authentication flow
[Section titled “Test complete authentication flow”](#test-complete-authentication-flow)
```python
1
import time
2
3
def test_complete_oauth_flow_integration():
4
"""
5
Integration test for complete OAuth authentication flow.
6
Requires manual intervention for OAuth consent.
7
"""
8
user_id = "integration_test_user"
9
provider = "gmail"
10
11
# Step 1: Create connected account
12
print("Step 1: Creating connected account...")
13
response = actions.get_or_create_connected_account(
14
connection_name=provider,
15
identifier=user_id
16
)
17
18
account = response.connected_account
19
assert account.status == "PENDING", f"Expected PENDING, got {account.status}"
20
print(f"✓ Connected account created: {account.id}")
21
22
# Step 2: Generate authorization link
23
print("\nStep 2: Generating authorization link...")
24
link_response = actions.get_authorization_link(
25
connection_name=provider,
26
identifier=user_id
27
)
28
29
print(f"✓ Authorization link: {link_response.link}")
30
print("\n⚠ MANUAL STEP: Open this link in a browser and complete OAuth")
31
print(" Press Enter after completing OAuth flow...")
32
input()
33
34
# Step 3: Verify account is now active
35
print("\nStep 3: Verifying account status...")
36
time.sleep(2) # Brief delay for processing
37
38
account = actions.get_connected_account(
39
identifier=user_id,
40
connection_name=provider
41
)
42
43
assert account.status == "ACTIVE", f"Expected ACTIVE, got {account.status}"
44
print(f"✓ Account is ACTIVE")
45
print(f" Granted scopes: {account.scopes}")
46
47
# Step 4: Test tool execution
48
print("\nStep 4: Testing tool execution...")
49
result = actions.execute_tool(
50
identifier=user_id,
51
tool_name="gmail_get_profile",
52
tool_input={}
53
)
54
55
assert result is not None, "Tool execution failed"
56
print(f"✓ Tool executed successfully")
57
58
print("\n✓✓✓ Integration test completed successfully")
59
60
# Run with: pytest test_auth_integration.py -s (to see output)
```
### Test error scenarios
[Section titled “Test error scenarios”](#test-error-scenarios)
```python
1
def test_error_scenarios():
2
"""Test various error scenarios"""
3
user_id = "error_test_user"
4
5
# Test 1: Invalid provider
6
print("Test 1: Invalid provider...")
7
try:
8
actions.get_or_create_connected_account(
9
connection_name="invalid_provider",
10
identifier=user_id
11
)
12
assert False, "Should have raised error"
13
except Exception as e:
14
print(f"✓ Caught expected error: {type(e).__name__}")
15
16
# Test 2: Execute tool without authentication
17
print("\nTest 2: Tool execution without auth...")
18
try:
19
actions.execute_tool(
20
identifier="nonexistent_user",
21
tool_name="gmail_send_email",
22
tool_input={"to": "test@example.com"}
23
)
24
assert False, "Should have raised error"
25
except Exception as e:
26
print(f"✓ Caught expected error: {type(e).__name__}")
27
28
# Test 3: Missing required scopes
29
print("\nTest 3: Missing required scopes...")
30
# This test requires setup with insufficient scopes
31
print("⚠ Skipped: Requires special setup")
32
33
print("\n✓✓✓ Error scenario tests completed")
```
## Automated testing
[Section titled “Automated testing”](#automated-testing)
### Test authentication in CI/CD
[Section titled “Test authentication in CI/CD”](#test-authentication-in-cicd)
.github/workflows/test-auth.yml
```yaml
1
name: Test Authentication Flows
2
3
on: [push, pull_request]
4
5
jobs:
6
test:
7
runs-on: ubuntu-latest
8
9
steps:
10
- uses: actions/checkout@v2
11
12
- name: Set up Python
13
uses: actions/setup-python@v2
14
with:
15
python-version: '3.9'
16
17
- name: Install dependencies
18
run: |
19
pip install -r requirements.txt
20
pip install pytest pytest-cov
21
22
- name: Run unit tests
23
env:
24
SCALEKIT_CLIENT_ID: ${{ secrets.TEST_CLIENT_ID }}
25
SCALEKIT_CLIENT_SECRET: ${{ secrets.TEST_CLIENT_SECRET }}
26
SCALEKIT_ENV_URL: ${{ secrets.TEST_ENV_URL }}
27
run: |
28
pytest tests/test_auth.py -v --cov=src/auth
29
30
- name: Run integration tests (non-OAuth)
31
env:
32
SCALEKIT_CLIENT_ID: ${{ secrets.TEST_CLIENT_ID }}
33
SCALEKIT_CLIENT_SECRET: ${{ secrets.TEST_CLIENT_SECRET }}
34
SCALEKIT_ENV_URL: ${{ secrets.TEST_ENV_URL }}
35
run: |
36
pytest tests/test_auth_integration.py -v -k "not oauth"
```
### Mock OAuth flows
[Section titled “Mock OAuth flows”](#mock-oauth-flows)
```python
1
from unittest.mock import patch, Mock
2
3
def test_oauth_flow_with_mocks():
4
"""Test OAuth flow with mocked responses (no actual OAuth)"""
5
6
with patch('scalekit.actions.get_or_create_connected_account') as mock_create, \
7
patch('scalekit.actions.get_authorization_link') as mock_link, \
8
patch('scalekit.actions.get_connected_account') as mock_get:
9
10
# Mock connected account creation
11
mock_account = Mock()
12
mock_account.id = "account_123"
13
mock_account.status = "PENDING"
14
15
mock_response = Mock()
16
mock_response.connected_account = mock_account
17
mock_create.return_value = mock_response
18
19
# Mock authorization link
20
mock_link_response = Mock()
21
mock_link_response.link = "https://mock-oauth-url.com"
22
mock_link.return_value = mock_link_response
23
24
# Mock successful authentication (simulate user completing OAuth)
25
mock_account.status = "ACTIVE"
26
mock_account.scopes = ["gmail.readonly", "gmail.send"]
27
mock_get.return_value = mock_account
28
29
# Test the flow
30
# 1. Create account
31
response = mock_create(connection_name="gmail", identifier="user_123")
32
assert response.connected_account.status == "PENDING"
33
34
# 2. Get auth link
35
link = mock_link(connection_name="gmail", identifier="user_123")
36
assert "https://" in link.link
37
38
# 3. Simulate user completing OAuth (status changes to ACTIVE)
39
account = mock_get(identifier="user_123", connection_name="gmail")
40
assert account.status == "ACTIVE"
41
assert len(account.scopes) > 0
42
43
print("✓ OAuth flow test with mocks completed")
```
## Performance testing
[Section titled “Performance testing”](#performance-testing)
### Test token refresh performance
[Section titled “Test token refresh performance”](#test-token-refresh-performance)
```python
1
import time
2
3
def test_token_refresh_performance():
4
"""Measure token refresh latency"""
5
user_id = "perf_test_user"
6
provider = "gmail"
7
8
# Setup: Create account with expired token
9
# (This requires manually setting up an expired account)
10
11
iterations = 10
12
refresh_times = []
13
14
for i in range(iterations):
15
start_time = time.time()
16
17
try:
18
actions.refresh_connected_account(
19
identifier=user_id,
20
connection_name=provider
21
)
22
elapsed = time.time() - start_time
23
refresh_times.append(elapsed)
24
print(f"Iteration {i+1}: {elapsed:.3f}s")
25
except Exception as e:
26
print(f"Iteration {i+1} failed: {e}")
27
28
if refresh_times:
29
avg_time = sum(refresh_times) / len(refresh_times)
30
min_time = min(refresh_times)
31
max_time = max(refresh_times)
32
33
print(f"\nToken Refresh Performance:")
34
print(f" Average: {avg_time:.3f}s")
35
print(f" Min: {min_time:.3f}s")
36
print(f" Max: {max_time:.3f}s")
37
38
# Assert reasonable performance (adjust threshold as needed)
39
assert avg_time < 2.0, f"Average refresh time too slow: {avg_time:.3f}s"
```
## Best practices
[Section titled “Best practices”](#best-practices)
### Test checklist
[Section titled “Test checklist”](#test-checklist)
1. **Unit tests** - Test individual authentication functions
2. **Integration tests** - Test complete OAuth flows
3. **Error handling** - Test all error scenarios
4. **Token refresh** - Test automatic and manual refresh
5. **Multi-provider** - Test multiple simultaneous connections
6. **Performance** - Measure and optimize latency
7. **Security** - Verify token encryption and secure storage
### Testing dos and don’ts
[Section titled “Testing dos and don’ts”](#testing-dos-and-donts)
✅ **Do:**
* Use separate test accounts for each provider
* Test both success and failure scenarios
* Mock external OAuth calls in unit tests
* Test token refresh before expiration
* Verify error messages are helpful
* Test with realistic data volumes
❌ **Don’t:**
* Use production accounts for testing
* Hardcode test credentials in source code
* Skip error scenario testing
* Assume OAuth always succeeds
* Neglect performance testing
* Test only happy path scenarios
### Security testing
[Section titled “Security testing”](#security-testing)
```python
1
def test_security_scenarios():
2
"""Test security-related authentication scenarios"""
3
4
# Test 1: Verify tokens are not exposed in logs
5
print("Test 1: Token exposure check...")
6
with patch('logging.Logger.debug') as mock_log:
7
account = actions.get_connected_account(
8
identifier="test_user",
9
connection_name="gmail"
10
)
11
12
# Verify no access tokens in log calls
13
for call in mock_log.call_args_list:
14
log_message = str(call)
15
assert "access_token" not in log_message.lower()
16
assert "refresh_token" not in log_message.lower()
17
18
print("✓ No tokens in logs")
19
20
# Test 2: Verify HTTPS for OAuth redirects
21
print("\nTest 2: HTTPS verification...")
22
link_response = actions.get_authorization_link(
23
connection_name="gmail",
24
identifier="test_user"
25
)
26
27
assert link_response.link.startswith("https://")
28
print("✓ OAuth uses HTTPS")
29
30
# Test 3: State parameter validation
31
print("\nTest 3: State parameter present...")
32
assert "state=" in link_response.link
33
print("✓ State parameter included")
34
35
print("\n✓✓✓ Security tests completed")
```
## Next steps
[Section titled “Next steps”](#next-steps)
* [Troubleshoot connection errors](/agentkit/authentication/troubleshooting) — Debug connection and tool call issues
* [Manage connected accounts](/agentkit/connected-accounts/) — Test multiple connections per user
---
# DOCUMENT BOUNDARY
---
# Troubleshoot connection errors
> Diagnose connection failures, connected account issues, and tool execution errors in AgentKit.
Use this guide when a connection fails during OAuth, a connected account shows an unexpected status, or a tool call fails. Start with the diagnostics below, then open the matching scenario.
For connection setup errors (redirect URI mismatch, session expiry, token exchange failures), also see [Common scenarios on Configure connections](/agentkit/connections/#common-scenarios).
## Start with diagnostics
[Section titled “Start with diagnostics”](#start-with-diagnostics)
Check the connected account status first. That tells you whether the user never finished OAuth, still needs identity verification, tokens expired, or the account is disconnected.
* Python
```python
1
account = scalekit_client.actions.get_connected_account(
2
identifier="user_123",
3
connection_name="github-connect",
4
)
5
6
print(account.status) # ACTIVE, EXPIRED, PENDING_AUTH, PENDING_VERIFICATION, or DISCONNECTED
7
print(account.scopes)
```
* Node.js
```typescript
1
const account = await scalekit.actions.getConnectedAccount({
2
identifier: 'user_123',
3
connectionName: 'github-connect',
4
});
5
6
console.log(account.status); // ACTIVE, EXPIRED, PENDING_AUTH, PENDING_VERIFICATION, or DISCONNECTED
7
console.log(account.scopes);
```
| Status | Meaning |
| ---------------------- | ---------------------------------------------------------------- |
| `ACTIVE` | Credentials are valid; tool calls should work |
| `EXPIRED` | Access token expired and needs refresh or re-authentication |
| `PENDING_AUTH` | User has not finished OAuth, or re-authentication is in progress |
| `PENDING_VERIFICATION` | OAuth succeeded; user identity verification is still required |
| `DISCONNECTED` | Account was manually disconnected |
If status is `ACTIVE` but a tool still fails, run a read-only tool (for example `github_user_get_authenticated`) to confirm the connection end to end. The error message usually points to scopes, credentials, or provider rate limits.
To catch status changes without polling, subscribe to `connected_account.status_updated`. For automatic refresh failures, also subscribe to `connected_account.token_refresh_failed`. See [Detect when re-authentication is needed](/agentkit/connected-accounts/#detect-when-re-authentication-is-needed) for payload details and filtering.
## Connected account status
[Section titled “Connected account status”](#connected-account-status)
Status is `PENDING_AUTH`
The user has not finished OAuth, or you initiated re-authentication and the user has not completed it yet. Generate an authorization link and send it through your app (email, in-app prompt, or settings page).
* Python
```python
1
if account.status == "PENDING_AUTH":
2
link = scalekit_client.actions.get_authorization_link(
3
connection_name="github-connect",
4
identifier="user_123",
5
)
6
print(link.link)
```
* Node.js
```typescript
1
if (account.status === 'PENDING_AUTH') {
2
const link = await scalekit.actions.getAuthorizationLink({
3
connectionName: 'github-connect',
4
identifier: 'user_123',
5
});
6
console.log(link.link);
7
}
```
Status changes to `ACTIVE` after the user completes OAuth (or to `PENDING_VERIFICATION` if your connection requires identity verification).
Status is `PENDING_VERIFICATION`
OAuth succeeded, but Scalekit is waiting for the user to complete identity verification before the account becomes `ACTIVE`. Send the user through your verification flow.
See [Verify user identity](/agentkit/user-verification/) for configuration and API calls. After verification succeeds, status should move to `ACTIVE`.
Status is `EXPIRED`
The access token expired and Scalekit could not refresh it automatically. Try a manual refresh first. If refresh fails, send the user a new authorization link.
If manual refresh always fails, or the account returns to `EXPIRED` soon after each re-authorization, the connection never received a refresh token. A provider issues one only when the connection requests offline access, so re-authorizing without it produces another short-lived token that expires again. Add the provider’s offline-access scope (for example `offline_access` or `refresh_token`) in [Configure scopes](/agentkit/connections/#configure-scopes), then have the user reconnect. See [why OAuth accounts expire](/agentkit/connected-accounts/#detect-when-re-authentication-is-needed) for the full list of causes.
* Python
```python
1
try:
2
account = scalekit_client.actions.refresh_connected_account(
3
identifier="user_123",
4
connection_name="github-connect",
5
)
6
if account.status != "ACTIVE":
7
link = scalekit_client.actions.get_authorization_link(
8
connection_name="github-connect",
9
identifier="user_123",
10
)
11
print(link.link)
12
except Exception as exc:
13
print(exc)
```
* Node.js
```typescript
1
try {
2
const refreshed = await scalekit.actions.refreshConnectedAccount({
3
identifier: 'user_123',
4
connectionName: 'github-connect',
5
});
6
if (refreshed.status !== 'ACTIVE') {
7
const link = await scalekit.actions.getAuthorizationLink({
8
connectionName: 'github-connect',
9
identifier: 'user_123',
10
});
11
console.log(link.link);
12
}
13
} catch (error) {
14
console.error(error);
15
}
```
Status is `DISCONNECTED`
The connected account was manually disconnected in Scalekit or the user removed your application’s access at the provider (for example **Google Account** > **Third-party access**). Re-authentication is the only fix.
Send a new authorization link and explain that the user disconnected the integration. Pending tool executions fail until the user reconnects.
Handle disconnected accounts in your app
Surface `DISCONNECTED` status in your UI and stop scheduling tool calls for that connected account until the user reconnects.
## OAuth flow errors
[Section titled “OAuth flow errors”](#oauth-flow-errors)
The provider returns an error on the callback URL
Read the `error` and `error_description` query parameters on the callback. Common values:
| Error | Meaning | What to do |
| --------------------- | ------------------------------- | ---------------------------------------------------- |
| `access_denied` | User cancelled consent | Offer to restart the flow |
| `invalid_request` | Malformed authorization request | Check scopes and connection configuration |
| `unauthorized_client` | OAuth client not authorized | Verify credentials in **AgentKit** > **Connections** |
| `invalid_scope` | Scope not valid for this app | Update scopes on the connection and retry |
| `server_error` | Provider-side failure | Retry after a few minutes; check provider status |
Log both parameters in development. Do not expose raw `error_description` text to end users.
`failed_to_exchange_token` after consent
Token exchange failed after the user approved access. See [Common scenarios on Configure connections](/agentkit/connections/#common-scenarios) for retry steps, status page checks, and what to send support.
Redirect URI mismatch
The redirect URI in the provider’s OAuth app must match the URI shown in Scalekit exactly — protocol, host, path, and trailing slashes included.
1. Open **AgentKit** > **Connections** and select the connection
2. Copy the **Redirect URI** from Scalekit
3. Paste it into the provider’s OAuth app settings (Google Cloud Console, Azure portal, and similar)
4. Save both sides and restart the connection flow
Match the string exactly
Watch for `http` vs `https`, missing or extra trailing slashes, and port numbers in local development.
Session expired or invalid on the callback page
The OAuth verification session timed out before the provider redirected back. Close the window and start the connection flow again. No configuration change is required.
See also [Common scenarios on Configure connections](/agentkit/connections/#common-scenarios).
Invalid state parameter
Scalekit validates the `state` parameter for CSRF protection. If you see this error:
1. Confirm cookies are enabled in the browser
2. Finish the flow in the same browser you started it in
3. Clear stale cookies and restart the flow
4. Check for large clock skew between client and server
## Tool execution failures
[Section titled “Tool execution failures”](#tool-execution-failures)
Tool fails with an auth error but status is `ACTIVE`
Work through these checks in order:
1. Confirm status with `get_connected_account`
2. Call `refresh_connected_account` / `refreshConnectedAccount`
3. Compare `account.scopes` to the scopes your tool requires
4. Run a read-only tool (for example `github_user_get_authenticated`) to isolate the failure
* Python
```python
1
account = scalekit_client.actions.get_connected_account(
2
identifier="user_123",
3
connection_name="github-connect",
4
)
5
6
scalekit_client.actions.refresh_connected_account(
7
identifier="user_123",
8
connection_name="github-connect",
9
)
10
11
result = scalekit_client.actions.execute_tool(
12
identifier="user_123",
13
tool_name="github_user_get_authenticated",
14
tool_input={},
15
)
16
print(result.data)
```
* Node.js
```typescript
1
const account = await scalekit.actions.getConnectedAccount({
2
identifier: 'user_123',
3
connectionName: 'github-connect',
4
});
5
6
await scalekit.actions.refreshConnectedAccount({
7
identifier: 'user_123',
8
connectionName: 'github-connect',
9
});
10
11
const result = await scalekit.actions.executeTool({
12
identifier: 'user_123',
13
toolName: 'github_user_get_authenticated',
14
toolInput: {},
15
});
16
console.log(result.data);
```
Insufficient permissions or forbidden errors
The user granted fewer scopes than your tool needs. Check granted scopes on the connected account, then send a new authorization link after you update scopes on the connection.
See [Configure scopes](/agentkit/connections/#configure-scopes) on the connections page. After you add scopes to a connection, existing users must re-authenticate.
## Provider-specific errors
[Section titled “Provider-specific errors”](#provider-specific-errors)
Connector works for your own account but fails for customers’ accounts
Some providers restrict an OAuth app to the account that created it until the app completes the provider’s review or publishing process. The connection then succeeds for your own workspace but fails for external customers, with errors such as “this app can’t be used outside of development” or a provider access-denied screen.
This can affect Scalekit’s prebuilt connectors too. The connector ships with a pre-filled `client_id`, and listing tools succeeds because your own account already authorized it, which can give the impression that any account can connect.
To resolve it:
1. Open the provider’s developer or app settings and complete the OAuth app profile the provider requires for external use, such as the logo, terms of service, and privacy policy links. Airtable requires this in its Builder Hub. ZoomInfo requires a partner application rather than a custom (internal-only) app.
2. Confirm the app is published or verified for use beyond your own account.
3. Confirm the `client_id`, redirect URL, and scopes match exactly between the provider and **AgentKit** > **Connections**. A mismatch points the connection at a different client and fails authorization.
4. Reconnect from an external account to confirm the fix.
Each provider sets its own policy
The exact requirement depends on the provider’s app-review policy, not on Scalekit. Check the provider’s documentation for how to publish or certify an OAuth app for use by accounts other than your own.
Google: “Access blocked” or “This app isn’t verified”
Google blocks unverified apps that request sensitive scopes, or the Workspace admin blocked third-party apps.
* During development, use test users or click **Advanced** > **Go to app (unsafe)** on the consent screen
* For production, complete [Google app verification](https://support.google.com/cloud/answer/9110914) or use less restrictive scopes
* Workspace admins may need to allowlist your OAuth client
Microsoft 365: `AADSTS65001` consent errors
The tenant has not granted consent for the permissions your connection requests.
1. Open the app registration in Azure portal
2. Confirm API permissions match your connection scopes
3. Grant admin consent if the tenant requires it
4. Retry the connection flow
Microsoft 365: `AADSTS50020` user not found
The signed-in account is not in the expected Microsoft 365 tenant, or the tenant blocks external applications. Confirm the user has a valid work or school account and that tenant policy allows your app.
Slack: OAuth access denied or workspace restrictions
The user may lack permission to install apps, or the workspace admin must approve the app first. Ask a workspace admin to approve the installation, or test with a workspace where you control app policy.
OAuth works for your own account but fails for external or customer accounts
Sometimes when you create an OAuth app in your connector’s provider environment, the provider sets it up in a development or testing mode that authorizes only the account that created the app. The connection then works while you test with your own account, but fails when your customers try to connect. For example, Airtable returns “OAuth app can’t be used outside development,” and ZoomInfo rejects a custom (internal) app.
Promote the provider app out of development mode, complete any required app profile, and re-sync the credentials. The [connector OAuth apps checklist](/agentkit/advanced/launch-checklist/#connector-oauth-apps-if-you-registered-your-own-app) lists the exact steps, and [Bring your own OAuth credentials](/agentkit/advanced/bring-your-own-oauth/) covers the setup.
## Configuration and rate limits
[Section titled “Configuration and rate limits”](#configuration-and-rate-limits)
Invalid client or client authentication failed
OAuth credentials on the connection do not match the provider’s console.
1. Open **AgentKit** > **Connections** and select the connection
2. Compare **Client ID** and **Client Secret** to the provider’s OAuth app
3. Regenerate the secret in the provider console if it may have rotated
4. Create a new connected account and retry OAuth
Authorization succeeds but tools fail on scope
The connection is missing scopes your tools require. Update scopes in the connection form, then have users re-authenticate. Scopes on existing tokens do not expand automatically.
Rate limit or quota exceeded
The provider rejected requests because you exceeded its quota. Back off and retry with exponential delay, reduce call frequency, and cache read results where possible.
Bring Your Own Credentials (BYOC) gives you a dedicated quota on providers that support separate OAuth apps. See your connector’s setup guide for BYOC steps.
## Get help
[Section titled “Get help”](#get-help)
Open **AgentKit** > **Connected Accounts** in the dashboard and review status, refresh history, and tool execution logs for the affected account.
When you contact [support](mailto:support@scalekit.com), include:
* Connected account ID or user `identifier`
* Connection name (for example `github-connect`, `slack`)
* Full error text and timestamp
* Steps that reproduce the failure
Related guides:
* [Configure connections](/agentkit/connections/) — setup, scopes, and common OAuth errors
* [Manage connected accounts](/agentkit/connected-accounts/) — per-user connection state and credentials
---
# DOCUMENT BOUNDARY
---
# Create your own connector
> Choose an auth type, build the connector payload, and create or manage custom connectors in Scalekit.
Create a custom connector to bring an unsupported API or MCP server into Scalekit’s secure access model. This guide walks you through building the connector payload, creating the connector, and managing it over its lifecycle - list, update, and delete - with the management API.
[Check out the examples](https://github.com/scalekit-inc/python-connect-demos/tree/main/custom-connectors)
Prerequisites
You need three credentials from your Scalekit environment:
* `SCALEKIT_ENVIRONMENT_URL` - the base URL of your Scalekit environment
* `SCALEKIT_CLIENT_ID` - your environment’s client ID
* `SCALEKIT_CLIENT_SECRET` - your environment’s client secret
Find these in the Scalekit Dashboard under **Developers → Settings → API Credentials**.
## Create a connector
[Section titled “Create a connector”](#create-a-connector)
Create a connector in the Scalekit Dashboard or with the management API. The dashboard provides a guided form for MCP connectors; the management API gives you scriptable control over every connector type and auth pattern.
### Create an MCP connector in the dashboard
[Section titled “Create an MCP connector in the dashboard”](#create-an-mcp-connector-in-the-dashboard)
Add an MCP connector through a guided form - no payload required.
1. In the Scalekit Dashboard, switch to **AgentKit**.
2. Select **Connectors**.
3. Select **Create custom connector**.
4. Complete the **Add MCP connector** form:
* **Display name**: a name for the connector, such as `Example MCP`.
* **Description**: a short description of what the connector connects to.
* **Icon URL** (optional): an icon for the connector. Must start with `https://`. For best results, use an 800×800px SVG image, such as `https://cdn.example.com/icon.svg`.
* **Server URL**: the base URL of the MCP server. Must start with `https://`, such as `https://app.example.com/mcp`.
* **Metadata** (optional): key-value pairs for the connector. Values must be plain strings; nested objects are not supported.
For **Auth type**, choose how users authenticate when they connect their account:
* **OAuth**: users authorize access through the provider’s OAuth flow, and Scalekit handles the token exchange.
* **Bearer token**: users provide a long-lived token issued by the provider.
* **API key**: users provide an API key issued by the provider.
* **No authentication**: the server is public and requires no credentials. Use this only when the server intentionally allows unauthenticated access and exposes no user-specific data or privileged operations, since every user shares the same anonymous access.
5. Select **Save**. The connector is ready to use when you create a connection.
### Create a connector with the management API
[Section titled “Create a connector with the management API”](#create-a-connector-with-the-management-api)
Build the connector payload using the reference and examples that follow, then create the connector with the management API.
Understand the connector payload
Supported auth types are `OAUTH`, `BASIC`, `BEARER`, and `API_KEY`. Use `OAUTH` when the upstream API or MCP server requires a user authorization flow and token exchange. Use `BASIC`, `BEARER`, or `API_KEY` when it accepts static credentials or long-lived tokens.
MCP providers use the same four auth types as REST API providers, with `is_mcp: true` set in each `auth_patterns[]` entry. OAuth MCP connectors use a simplified `oauth_config: {"pkce_enabled": true}` - the MCP server handles authorization via Dynamic Client Registration. Non-OAuth MCP connectors omit `oauth_config` entirely. MCP connectors can also use `NO_AUTH` for public servers that require no credentials - set `is_mcp: true`, use an empty `fields: []`, and omit `oauth_config`. Use `NO_AUTH` only when the upstream intentionally allows unauthenticated public access and exposes no user-specific data or privileged operations; every user of the connector shares the same anonymous access.
The connector payload uses these common top-level fields:
* `display_name`: Human-readable name for the custom connector
* `description`: Short description of what the connector connects to
* `auth_patterns`: Authentication options supported by the connector
* `proxy_url`: Base URL the proxy should call for the upstream API (mandatory)
* `proxy_enabled`: Whether the proxy is enabled for the connector (mandatory, should be true)
`proxy_url` can also include templated fields when the upstream API requires account-specific values, for example `https://{{domain}}/api`.
Within `auth_patterns`, the most common fields are:
* `type`: The auth type, such as OAUTH, BASIC, BEARER, or API\_KEY
* `display_name`: Label shown for that auth option
* `description`: Short explanation of the auth method
* `fields`: Inputs collected for static auth providers such as BASIC, BEARER, and API\_KEY. These usually store values such as `username`, `password`, `token`, `api_key`, `domain`, or `version`.
* `account_fields`: Inputs collected for OAUTH connectors when account-scoped values are needed. This is typically used for values tied to a connected account, such as named path parameters.
* `oauth_config`: OAuth-specific configuration, such as authorize and token endpoints
* `auth_header_key_override`: Custom header name when the upstream does not use `Authorization`. For example, some APIs expect auth in a header such as `X-API-Key` instead of the standard `Authorization` header.
* `auth_field_mutations`: Value transformations applied before the credential is sent. This is useful when the upstream expects a prefix, suffix, or default companion value, such as adding a token prefix or setting a fallback password value for Basic auth.
* `is_mcp`: Set to `true` when the upstream is an MCP server. Tells Scalekit to route the connector through MCP tool calling instead of the HTTP proxy.
Below are example payloads for API and MCP connectors across all supported auth patterns.
Stateless MCP servers only
Scalekit connects to **stateless MCP servers** only. Stateful MCP servers that require persistent sticky connections or MCP session IDs are not supported.
* API Connector
* OAuth
```json
1
{
2
"display_name": "My Asana",
3
"description": "Connect to Asana. Manage tasks, projects, teams, and workflow automation",
4
"auth_patterns": [
5
{
6
"type": "OAUTH",
7
"display_name": "OAuth 2.0",
8
"description": "Authenticate with Asana using OAuth 2.0 for comprehensive project management",
9
"fields": [],
10
"oauth_config": {
11
"authorize_uri": "https://app.asana.com/-/oauth_authorize",
12
"token_uri": "https://app.asana.com/-/oauth_token",
13
"user_info_uri": "https://app.asana.com/api/1.0/users/me",
14
"available_scopes": [
15
{
16
"scope": "profile",
17
"display_name": "Profile",
18
"description": "Access user profile information",
19
"required": true
20
},
21
{
22
"scope": "email",
23
"display_name": "Email",
24
"description": "Access user email address",
25
"required": true
26
}
27
]
28
}
29
}
30
],
31
"proxy_url": "https://app.asana.com/api",
32
"proxy_enabled": true
33
}
```
* Bearer
```json
1
{
2
"display_name": "My Bearer Token Provider",
3
"description": "Connect to an API that accepts a static bearer token",
4
"auth_patterns": [
5
{
6
"type": "BEARER",
7
"display_name": "Bearer Token",
8
"description": "Authenticate with a static bearer token",
9
"fields": [
10
{
11
"field_name": "token",
12
"label": "Bearer Token",
13
"input_type": "password",
14
"hint": "Your long-lived bearer token",
15
"required": true
16
}
17
]
18
}
19
],
20
"proxy_url": "https://api.example.com",
21
"proxy_enabled": true
22
}
```
* Basic
```json
1
{
2
"display_name": "My Freshdesk",
3
"description": "Connect to Freshdesk. Manage tickets, contacts, companies, and customer support workflows",
4
"auth_patterns": [
5
{
6
"type": "BASIC",
7
"display_name": "Basic Auth",
8
"description": "Authenticate with Freshdesk using Basic Auth with username and password for comprehensive helpdesk management",
9
"fields": [
10
{
11
"field_name": "domain",
12
"label": "Freshdesk Domain",
13
"input_type": "text",
14
"hint": "Your Freshdesk domain (e.g., yourcompany.freshdesk.com)",
15
"required": true
16
},
17
{
18
"field_name": "username",
19
"label": "API Key",
20
"input_type": "text",
21
"hint": "Your Freshdesk API Key",
22
"required": true
23
}
24
]
25
}
26
],
27
"proxy_url": "https://{{domain}}/api",
28
"proxy_enabled": true
29
}
```
* API Key
```json
1
{
2
"display_name": "My Attention",
3
"description": "Connect to Attention for AI insights, conversations, teams, and workflows",
4
"auth_patterns": [
5
{
6
"type": "API_KEY",
7
"display_name": "API Key",
8
"description": "Authenticate with Attention using an API Key",
9
"fields": [
10
{
11
"field_name": "api_key",
12
"label": "Integration Token",
13
"input_type": "password",
14
"hint": "Your Attention API Key",
15
"required": true
16
}
17
]
18
}
19
],
20
"proxy_url": "https://api.attention.tech",
21
"proxy_enabled": true
22
}
```
* MCP Connector
```json
1
{
2
"display_name": "My Asana",
3
"description": "Connect to Asana. Manage tasks, projects, teams, and workflow automation",
4
"auth_patterns": [
5
{
6
"type": "OAUTH",
7
"display_name": "OAuth 2.0",
8
"description": "Authenticate with Asana using OAuth 2.0 for comprehensive project management",
9
"fields": [],
10
"oauth_config": {
11
"authorize_uri": "https://app.asana.com/-/oauth_authorize",
12
"token_uri": "https://app.asana.com/-/oauth_token",
13
"user_info_uri": "https://app.asana.com/api/1.0/users/me",
14
"available_scopes": [
15
{
16
"scope": "profile",
17
"display_name": "Profile",
18
"description": "Access user profile information",
19
"required": true
20
},
21
{
22
"scope": "email",
23
"display_name": "Email",
24
"description": "Access user email address",
25
"required": true
26
}
27
]
28
}
29
}
30
],
31
"proxy_url": "https://app.asana.com/api",
32
"proxy_enabled": true
33
}
```
* OAuth
```json
1
{
2
"display_name": "My Bearer Token Provider",
3
"description": "Connect to an API that accepts a static bearer token",
4
"auth_patterns": [
5
{
6
"type": "BEARER",
7
"display_name": "Bearer Token",
8
"description": "Authenticate with a static bearer token",
9
"fields": [
10
{
11
"field_name": "token",
12
"label": "Bearer Token",
13
"input_type": "password",
14
"hint": "Your long-lived bearer token",
15
"required": true
16
}
17
]
18
}
19
],
20
"proxy_url": "https://api.example.com",
21
"proxy_enabled": true
22
}
```
* Bearer
```json
1
{
2
"display_name": "My Freshdesk",
3
"description": "Connect to Freshdesk. Manage tickets, contacts, companies, and customer support workflows",
4
"auth_patterns": [
5
{
6
"type": "BASIC",
7
"display_name": "Basic Auth",
8
"description": "Authenticate with Freshdesk using Basic Auth with username and password for comprehensive helpdesk management",
9
"fields": [
10
{
11
"field_name": "domain",
12
"label": "Freshdesk Domain",
13
"input_type": "text",
14
"hint": "Your Freshdesk domain (e.g., yourcompany.freshdesk.com)",
15
"required": true
16
},
17
{
18
"field_name": "username",
19
"label": "API Key",
20
"input_type": "text",
21
"hint": "Your Freshdesk API Key",
22
"required": true
23
}
24
]
25
}
26
],
27
"proxy_url": "https://{{domain}}/api",
28
"proxy_enabled": true
29
}
```
* Basic
```json
1
{
2
"display_name": "My Attention",
3
"description": "Connect to Attention for AI insights, conversations, teams, and workflows",
4
"auth_patterns": [
5
{
6
"type": "API_KEY",
7
"display_name": "API Key",
8
"description": "Authenticate with Attention using an API Key",
9
"fields": [
10
{
11
"field_name": "api_key",
12
"label": "Integration Token",
13
"input_type": "password",
14
"hint": "Your Attention API Key",
15
"required": true
16
}
17
]
18
}
19
],
20
"proxy_url": "https://api.attention.tech",
21
"proxy_enabled": true
22
}
```
* API Key
* OAuth
```json
1
{
2
"display_name": "Github MCP",
3
"description": "Connect to Github MCP",
4
"auth_patterns": [
5
{
6
"description": "Authenticate with Github MCP using browser OAuth.",
7
"display_name": "OAuth 2.1/DCR",
8
"fields": [],
9
"is_mcp": true,
10
"oauth_config": {
11
"pkce_enabled": true
12
},
13
"type": "OAUTH"
14
}
15
],
16
"proxy_url": "https://api.githubcopilot.com/mcp/",
17
"proxy_enabled": true
18
}
```
* Bearer
```json
1
{
2
"display_name": "Apify MCP",
3
"description": "Connect to Apify MCP to run web scraping, browser automation, and data extraction Actors directly from your AI workflows.",
4
"auth_patterns": [
5
{
6
"description": "Authenticate with Apify using your API Token.",
7
"display_name": "Apify Token",
8
"fields": [
9
{
10
"field_name": "token",
11
"hint": "Your Apify API Token",
12
"input_type": "password",
13
"label": "Apify Token",
14
"required": true
15
}
16
],
17
"is_mcp": true,
18
"type": "BEARER"
19
}
20
],
21
"proxy_url": "https://mcp.apify.com",
22
"proxy_enabled": true
23
}
```
* Basic
```json
1
{
2
"display_name": "My Internal MCP",
3
"description": "Connect to an internal MCP server that authenticates with a username and password",
4
"auth_patterns": [
5
{
6
"type": "BASIC",
7
"display_name": "Basic Auth",
8
"description": "Authenticate with a username and password",
9
"is_mcp": true,
10
"fields": [
11
{
12
"field_name": "username",
13
"label": "Username",
14
"input_type": "text",
15
"hint": "Your username",
16
"required": true
17
},
18
{
19
"field_name": "password",
20
"label": "Password",
21
"input_type": "password",
22
"hint": "Your password",
23
"required": true
24
}
25
]
26
}
27
],
28
"proxy_url": "https://mcp.internal.example.com",
29
"proxy_enabled": true
30
}
```
* API Key
```json
1
{
2
"display_name": "My API Key MCP",
3
"description": "Connect to an MCP server that authenticates with a static API key",
4
"auth_patterns": [
5
{
6
"type": "API_KEY",
7
"display_name": "API Key",
8
"description": "Authenticate with a static API key",
9
"is_mcp": true,
10
"fields": [
11
{
12
"field_name": "api_key",
13
"label": "API Key",
14
"input_type": "password",
15
"hint": "Your API key",
16
"required": true
17
}
18
]
19
}
20
],
21
"proxy_url": "https://mcp.example.com",
22
"proxy_enabled": true
23
}
```
* No Auth
```json
1
{
2
"display_name": "Public Docs MCP",
3
"description": "Connect to a public MCP server that requires no credentials",
4
"auth_patterns": [
5
{
6
"type": "NO_AUTH",
7
"display_name": "No Auth",
8
"description": "Public server - no credentials required.",
9
"is_mcp": true,
10
"fields": []
11
}
12
],
13
"proxy_url": "https://mcp.example.com",
14
"proxy_enabled": true
15
}
```
* OAuth
```json
1
{
2
"display_name": "Github MCP",
3
"description": "Connect to Github MCP",
4
"auth_patterns": [
5
{
6
"description": "Authenticate with Github MCP using browser OAuth.",
7
"display_name": "OAuth 2.1/DCR",
8
"fields": [],
9
"is_mcp": true,
10
"oauth_config": {
11
"pkce_enabled": true
12
},
13
"type": "OAUTH"
14
}
15
],
16
"proxy_url": "https://api.githubcopilot.com/mcp/",
17
"proxy_enabled": true
18
}
```
* Bearer
```json
1
{
2
"display_name": "Apify MCP",
3
"description": "Connect to Apify MCP to run web scraping, browser automation, and data extraction Actors directly from your AI workflows.",
4
"auth_patterns": [
5
{
6
"description": "Authenticate with Apify using your API Token.",
7
"display_name": "Apify Token",
8
"fields": [
9
{
10
"field_name": "token",
11
"hint": "Your Apify API Token",
12
"input_type": "password",
13
"label": "Apify Token",
14
"required": true
15
}
16
],
17
"is_mcp": true,
18
"type": "BEARER"
19
}
20
],
21
"proxy_url": "https://mcp.apify.com",
22
"proxy_enabled": true
23
}
```
* Basic
```json
1
{
2
"display_name": "My Internal MCP",
3
"description": "Connect to an internal MCP server that authenticates with a username and password",
4
"auth_patterns": [
5
{
6
"type": "BASIC",
7
"display_name": "Basic Auth",
8
"description": "Authenticate with a username and password",
9
"is_mcp": true,
10
"fields": [
11
{
12
"field_name": "username",
13
"label": "Username",
14
"input_type": "text",
15
"hint": "Your username",
16
"required": true
17
},
18
{
19
"field_name": "password",
20
"label": "Password",
21
"input_type": "password",
22
"hint": "Your password",
23
"required": true
24
}
25
]
26
}
27
],
28
"proxy_url": "https://mcp.internal.example.com",
29
"proxy_enabled": true
30
}
```
* API Key
```json
1
{
2
"display_name": "My API Key MCP",
3
"description": "Connect to an MCP server that authenticates with a static API key",
4
"auth_patterns": [
5
{
6
"type": "API_KEY",
7
"display_name": "API Key",
8
"description": "Authenticate with a static API key",
9
"is_mcp": true,
10
"fields": [
11
{
12
"field_name": "api_key",
13
"label": "API Key",
14
"input_type": "password",
15
"hint": "Your API key",
16
"required": true
17
}
18
]
19
}
20
],
21
"proxy_url": "https://mcp.example.com",
22
"proxy_enabled": true
23
}
```
* No Auth
```json
1
{
2
"display_name": "Public Docs MCP",
3
"description": "Connect to a public MCP server that requires no credentials",
4
"auth_patterns": [
5
{
6
"type": "NO_AUTH",
7
"display_name": "No Auth",
8
"description": "Public server - no credentials required.",
9
"is_mcp": true,
10
"fields": []
11
}
12
],
13
"proxy_url": "https://mcp.example.com",
14
"proxy_enabled": true
15
}
```
**Before submitting, review the final payload carefully:**
* `display_name` and `description`
* The selected auth `type`
* Required `fields` and `account_fields`
* OAuth endpoints and scopes, if the connector uses OAuth
* `proxy_url`
* Whether `is_mcp` is set to `true` for MCP providers
Generate an access token
All API requests require a short-lived access token. Generate one using your `SCALEKIT_CLIENT_ID` and `SCALEKIT_CLIENT_SECRET`:
```bash
1
curl --location "$SCALEKIT_ENVIRONMENT_URL/oauth/token" \
2
--header 'Content-Type: application/x-www-form-urlencoded' \
3
--data-urlencode 'grant_type=client_credentials' \
4
--data-urlencode "client_id=$SCALEKIT_CLIENT_ID" \
5
--data-urlencode "client_secret=$SCALEKIT_CLIENT_SECRET"
```
Use the `access_token` value from the response as `$env_access_token` in the `curl` commands below.
Use the payload for your auth type as the request body in the create request:
* cURL
Terminal
```bash
1
# $env_access_token and $SCALEKIT_CLIENT_SECRET are secrets - keep them server-side and out of source control.
2
# --fail-with-body makes curl exit non-zero and print the error body on a non-2xx response.
3
curl --fail-with-body --location "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers" \
4
--header "Authorization: Bearer $env_access_token" \
5
--header "Content-Type: application/json" \
6
--data '{...}'
```
* Python
The Python SDK builds the payload with typed request objects and authenticates using your client credentials - no separate access token step is needed. It covers MCP connector auth types: OAuth (via Dynamic Client Registration), Bearer, API key, and No Auth. The example below creates an OAuth MCP connector; swap the `AuthPattern` for the auth type you need.
create\_connector.py
```python
1
import scalekit.client, os
2
from dotenv import load_dotenv
3
from scalekit.actions.types import AuthPattern, OAuthConfig, CreateCustomProviderRequest
4
from scalekit.common.exceptions import ScalekitException
5
load_dotenv()
6
7
# Load credentials from the environment. Keep SCALEKIT_CLIENT_SECRET server-side -
8
# never commit it or expose it in client-side code.
9
scalekit_client = scalekit.client.ScalekitClient(
10
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
11
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
12
env_url=os.getenv("SCALEKIT_ENV_URL"),
13
)
14
15
try:
16
response = scalekit_client.actions.providers.create_custom_provider(
17
CreateCustomProviderRequest(
18
display_name="Github MCP",
19
description="Connect to Github MCP",
20
proxy_url="https://api.githubcopilot.com/mcp/",
21
proxy_enabled=True,
22
auth_patterns=[
23
AuthPattern(
24
type="OAUTH",
25
display_name="OAuth 2.1/DCR",
26
description="Authenticate with Github MCP using browser OAuth.",
27
is_mcp=True,
28
oauth_config=OAuthConfig(), # pkce_enabled=True by default
29
)
30
],
31
# Optional: icon_src="https://cdn.example.com/icon.svg",
32
# Optional: metadata={"team": "platform"},
33
)
34
)
35
print("Created connector:", response.provider.identifier)
36
except ScalekitException as err:
37
# Handle validation errors, conflicts (duplicate name), auth failures, etc.
38
print("Failed to create connector:", err)
39
raise
```
A successful request returns the created connector. Next, create a connection in the Scalekit Dashboard, then continue with the standard connector flow to authorize users and call tools.
## List connectors
[Section titled “List connectors”](#list-connectors)
List existing connectors before you create one, to confirm whether a connector for the upstream already exists. You also need the list to find a connector’s `identifier` for update and delete requests.
* cURL
Terminal
```bash
1
# $env_access_token is a secret - keep it server-side and out of source control.
2
curl --fail-with-body --location "$SCALEKIT_ENVIRONMENT_URL/api/v1/providers?filter.provider_type=CUSTOM&page_size=1000" \
3
--header "Authorization: Bearer $env_access_token"
```
* Python
list\_connectors.py
```python
1
import scalekit.client, os
2
from dotenv import load_dotenv
3
from scalekit.actions.types import ListProvidersRequest
4
from scalekit.v1.providers.providers_pb2 import ProviderType
5
from scalekit.common.exceptions import ScalekitException
6
load_dotenv()
7
8
# Keep SCALEKIT_CLIENT_SECRET server-side - never commit it or expose it client-side.
9
scalekit_client = scalekit.client.ScalekitClient(
10
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
11
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
12
env_url=os.getenv("SCALEKIT_ENV_URL"),
13
)
14
15
try:
16
response = scalekit_client.actions.providers.list_providers(
17
ListProvidersRequest(provider_type=ProviderType.CUSTOM, page_size=1000)
18
)
19
for provider in response.providers:
20
print(provider.identifier, provider.display_name)
21
except ScalekitException as err:
22
print("Failed to list connectors:", err)
23
raise
```
## Update a connector
[Section titled “Update a connector”](#update-a-connector)
Use the [List connectors](#list-connectors) API to get the connector `identifier`, then send the updated payload. Include `display_name`, `proxy_url`, and `auth_patterns` on every update, and echo back any other fields you want to keep - omitted fields are not preserved, so read the current connector first and change only what you need.
* cURL
Terminal
```bash
1
# $env_access_token and $SCALEKIT_CLIENT_SECRET are secrets - keep them server-side and out of source control.
2
curl --fail-with-body --location --request PUT "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers/$PROVIDER_IDENTIFIER" \
3
--header "Authorization: Bearer $env_access_token" \
4
--header "Content-Type: application/json" \
5
--data '{...}'
```
* Python
update\_connector.py
```python
1
import scalekit.client, os
2
from dotenv import load_dotenv
3
from scalekit.actions.types import ListProvidersRequest, UpdateCustomProviderRequest
4
from scalekit.common.exceptions import ScalekitException
5
load_dotenv()
6
7
# Keep SCALEKIT_CLIENT_SECRET server-side - never commit it or expose it client-side.
8
scalekit_client = scalekit.client.ScalekitClient(
9
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
10
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
11
env_url=os.getenv("SCALEKIT_ENV_URL"),
12
)
13
14
provider_identifier = "prov_..." # from list_providers
15
16
try:
17
# Read the current state, then echo back every field you want to keep.
18
current = scalekit_client.actions.providers.list_providers(
19
ListProvidersRequest(identifier=provider_identifier)
20
).providers[0]
21
22
response = scalekit_client.actions.providers.update_custom_provider(
23
UpdateCustomProviderRequest(
24
identifier=current.identifier,
25
display_name=current.display_name,
26
proxy_url=current.proxy_url,
27
description="Updated description",
28
auth_patterns=current.auth_patterns,
29
metadata=dict(current.metadata),
30
)
31
)
32
print("Updated connector:", response.provider.identifier)
33
except ScalekitException as err:
34
print("Failed to update connector:", err)
35
raise
```
## Delete a connector
[Section titled “Delete a connector”](#delete-a-connector)
Use the [List connectors](#list-connectors) API to get the connector `identifier`. If the connector is still in use, remove the related connections or connected accounts first.
* cURL
Terminal
```bash
1
# $env_access_token is a secret - keep it server-side and out of source control.
2
curl --fail-with-body --location --request DELETE "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers/$PROVIDER_IDENTIFIER" \
3
--header "Authorization: Bearer $env_access_token"
```
* Python
delete\_connector.py
```python
1
import scalekit.client, os
2
from dotenv import load_dotenv
3
from scalekit.actions.types import DeleteCustomProviderRequest
4
from scalekit.common.exceptions import ScalekitException
5
load_dotenv()
6
7
# Keep SCALEKIT_CLIENT_SECRET server-side - never commit it or expose it client-side.
8
scalekit_client = scalekit.client.ScalekitClient(
9
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
10
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
11
env_url=os.getenv("SCALEKIT_ENV_URL"),
12
)
13
14
try:
15
scalekit_client.actions.providers.delete_custom_provider(
16
DeleteCustomProviderRequest(identifier="prov_...")
17
)
18
print("Connector deleted.")
19
except ScalekitException as err:
20
# e.g. not found, or forbidden if the connector is still in use.
21
print("Failed to delete connector:", err)
22
raise
```
## Next steps
[Section titled “Next steps”](#next-steps)
With the connector created and a connection in place, authorize a user and start calling the upstream:
* [Making tool calls](/agentkit/bring-your-own-connector/making-tool-calls) - call the upstream API or MCP server through your connector.
---
# DOCUMENT BOUNDARY
---
# Making tool calls
> Make tool calls using a REST API connector via Tool Proxy, or discover and execute tools from a custom MCP connector.
Use this page to make tool calls after the connector, connection, and connected account are set up.
The call method depends on the connector type:
* **REST API connectors** — use `actions.request()` to proxy HTTP calls through Tool Proxy
* **MCP connectors** — use `list_scoped_tools` to discover available tools, then `execute_tool` to call them
Both types use the same connection, connected account, and user authorization model.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
Make sure:
* The connector exists and is configured with the right [auth pattern](/agentkit/bring-your-own-connector/create-connector)
* A [connection](/agentkit/connections) is configured for the connector
* The [connected account](/agentkit/connected-accounts) exists
* The user has completed [authorization](/agentkit/tools/authorize)
Create a connection for your connector in the Scalekit Dashboard:

After the user completes authorization, the connected account appears in the Connected Accounts tab:

## REST API proxy calls
[Section titled “REST API proxy calls”](#rest-api-proxy-calls)
In the request examples below, `path` is relative to the connector `proxy_url`. `connectionName` must match the connection you created, and `identifier` must match the connected account you want to use for the request.
* Node.js
```typescript
1
import { ScalekitClient } from '@scalekit-sdk/node';
2
import 'dotenv/config';
3
4
const connectionName = 'your-provider-connection'; // get your connection name from connection configurations
5
const identifier = 'user_123'; // your unique user identifier
6
7
// Get your credentials from app.scalekit.com → Developers → Settings → API Credentials
8
const scalekit = new ScalekitClient(
9
process.env.SCALEKIT_ENV_URL,
10
process.env.SCALEKIT_CLIENT_ID,
11
process.env.SCALEKIT_CLIENT_SECRET
12
);
13
const actions = scalekit.actions;
14
15
// Authenticate the user
16
const { link } = await actions.getAuthorizationLink({
17
connectionName,
18
identifier,
19
});
20
console.log('Authorize connector:', link);
21
process.stdout.write('Press Enter after authorizing...');
22
await new Promise(r => process.stdin.once('data', r));
23
24
// Make a request via Scalekit proxy
25
const result = await actions.request({
26
connectionName,
27
identifier,
28
path: '/v1/customers',
29
method: 'GET',
30
});
31
console.log(result);
```
* Python
```python
1
import scalekit.client, os
2
from dotenv import load_dotenv
3
load_dotenv()
4
5
connection_name = "your-provider-connection" # get your connection name from connection configurations
6
identifier = "user_123" # your unique user identifier
7
8
# Get your credentials from app.scalekit.com → Developers → Settings → API Credentials
9
scalekit_client = scalekit.client.ScalekitClient(
10
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
11
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
12
env_url=os.getenv("SCALEKIT_ENV_URL"),
13
)
14
actions = scalekit_client.actions
15
16
# Authenticate the user
17
link_response = actions.get_authorization_link(
18
connection_name=connection_name,
19
identifier=identifier
20
)
21
# present this link to your user for authorization, or click it yourself for testing
22
print("Authorize connector:", link_response.link)
23
input("Press Enter after authorizing...")
24
25
# Make a request via Scalekit proxy
26
result = actions.request(
27
connection_name=connection_name,
28
identifier=identifier,
29
path="/v1/customers",
30
method="GET"
31
)
32
print(result)
```
The request shape stays the same regardless of auth type — the connector definition controls how Scalekit authenticates the call.
## MCP tool calling
[Section titled “MCP tool calling”](#mcp-tool-calling)
MCP connectors expose tools from the upstream MCP server. Discover the available tools, then execute them by name.
Discover available tools (optional)
If you already know the tool names from the Scalekit Dashboard, you can skip this step.
Call `list_scoped_tools` with the connection name to see which tools the MCP server exposes for a given user.
* Node.js
```typescript
1
import { ScalekitClient } from '@scalekit-sdk/node';
2
import 'dotenv/config';
3
4
const scalekit = new ScalekitClient(
5
process.env.SCALEKIT_ENV_URL,
6
process.env.SCALEKIT_CLIENT_ID,
7
process.env.SCALEKIT_CLIENT_SECRET
8
);
9
10
const connectionName = 'your-mcp-connection'; // connection name from Scalekit Dashboard
11
const identifier = 'user_123'; // your unique user identifier
12
13
const scoped = await scalekit.tools.listScopedTools(identifier, {
14
filter: { connectionNames: [connectionName] },
15
pageSize: 100,
16
});
17
18
const toolNames = scoped.tools?.map((st) => st.tool?.definition?.name) ?? [];
19
console.log('Available tools:', toolNames);
```
* Python
```python
1
import scalekit.client, os
2
from dotenv import load_dotenv
3
load_dotenv()
4
5
scalekit_client = scalekit.client.ScalekitClient(
6
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
7
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
8
env_url=os.getenv("SCALEKIT_ENV_URL"),
9
)
10
11
connection_name = "your-mcp-connection" # connection name from Scalekit Dashboard
12
identifier = "user_123" # your unique user identifier
13
14
response, _ = scalekit_client.tools.list_scoped_tools(
15
identifier=identifier,
16
filter={"connection_names": [connection_name]},
17
page_size=100,
18
)
19
20
tool_names = [scoped_tool.tool.definition["name"] for scoped_tool in response.tools]
21
print("Available tools:", tool_names)
```
Call `execute_tool` with the connection name, identifier, and any tool-specific input. Tool output lives in `response.data` — see [Understand tool response shape](/agentkit/tools/scalekit-optimized-tools/#understand-tool-response-shape) before parsing results.
* Node.js
```typescript
1
const actions = scalekit.actions;
2
3
const result = await actions.executeTool({
4
toolName: 'tool_name_from_discovery', // replace with a name from list_scoped_tools
5
connector: 'your-mcp-connection',
6
identifier: 'user_123',
7
toolInput: { key: 'value' }, // replace with the tool's required input
8
});
9
console.log(result.data);
```
* Python
```python
1
actions = scalekit_client.actions
2
3
result = actions.execute_tool(
4
tool_name="tool_name_from_discovery", # replace with a name from list_scoped_tools
5
connection_name="your-mcp-connection",
6
identifier="user_123",
7
tool_input={"key": "value"}, # replace with the tool's required input
8
)
9
print(result.data)
```
---
# DOCUMENT BOUNDARY
---
# AgentKit code samples
> Code samples of AI agents using Scalekit along with LangChain, Google ADK, and direct integrations
### [Connect LangChain agents to Gmail](https://github.com/scalekit-inc/sample-langchain-agent)
[Securely connect a LangChain agent to Gmail using Scalekit for authentication. Python example for tool authorization.](https://github.com/scalekit-inc/sample-langchain-agent)
### [Connect Google GenAI agents to Gmail](https://github.com/scalekit-inc/google-adk-agent-example)
[Build a Google ADK agent that securely accesses Gmail tools. Python example demonstrating Scalekit auth integration.](https://github.com/scalekit-inc/google-adk-agent-example)
### [Connect agents to Slack tools](https://github.com/scalekit-inc/python-connect-demos/tree/main/direct)
[Authorize Python agents to use Slack tools with Scalekit. Direct integration example for secure tool access.](https://github.com/scalekit-inc/python-connect-demos/tree/main/direct)
### [Browse all agent auth examples](https://github.com/scalekit-developers/agent-auth-examples)
[A curated collection of working examples showing how to build agents that authenticate and access tools using Scalekit.](https://github.com/scalekit-developers/agent-auth-examples)
---
# DOCUMENT BOUNDARY
---
# Manage connected accounts
> Check status, list, delete, and update credentials for connected accounts across all connector auth types.
A **connected account** is the per-user record that holds a user’s credentials and tracks their authorization state for a specific connection. Scalekit creates one automatically when a user completes authentication.
## Account states
[Section titled “Account states”](#account-states)
| State | Meaning |
| ---------------------- | --------------------------------------------------------------------------- |
| `ACTIVE` | Credentials valid, ready for tool calls |
| `EXPIRED` | Access token expired and needs refresh or re-authentication |
| `PENDING_AUTH` | User hasn’t completed authentication, or re-authentication is in progress |
| `PENDING_VERIFICATION` | OAuth complete; user identity verification still required before activation |
| `DISCONNECTED` | Account was manually disconnected |
See [Troubleshoot connection errors](/agentkit/authentication/troubleshooting/#connected-account-status) for what to do in each state.
## Check account status
[Section titled “Check account status”](#check-account-status)
Use `get_or_create_connected_account` as the safe default when a user may be connecting for the first time. Use `get_connected_account` only when you know the account already exists and you need to inspect or return its stored auth details.
* Python
```python
1
response = actions.get_or_create_connected_account(
2
connection_name="github-connect",
3
identifier="user_123"
4
)
5
connected_account = response.connected_account
6
print(f"Status: {connected_account.status}")
```
* Node.js
```typescript
1
const response = await actions.getOrCreateConnectedAccount({
2
connectionName: 'github-connect',
3
identifier: 'user_123',
4
});
5
6
console.log('Status:', response.connectedAccount?.status);
```
## Handle inactive accounts
[Section titled “Handle inactive accounts”](#handle-inactive-accounts)
When a connected account isn’t `ACTIVE`, generate a new authorization link and send it to the user.
The link opens a **Hosted Page**, a Scalekit-hosted UI that adapts automatically based on the connection’s auth type:
* **OAuth connectors**: presents the provider’s OAuth consent screen
* **API key, basic auth, or other connectors**: presents a form to collect the required credentials
Your code is the same regardless of connector type. Scalekit determines the right flow based on the connection configuration.
* Python
```python
1
if connected_account.status != "ACTIVE":
2
link_response = actions.get_authorization_link(
3
connection_name="github-connect",
4
identifier="user_123"
5
)
6
# Redirect or send link_response.link to the user
```
* Node.js
```typescript
1
import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb';
2
3
if (connectedAccount?.status !== ConnectorStatus.ACTIVE) {
4
const linkResponse = await actions.getAuthorizationLink({
5
connectionName: 'github-connect',
6
identifier: 'user_123',
7
});
8
// Redirect or send linkResponse.link to the user
9
}
```
Customize hosted pages
By default, hosted pages use Scalekit’s branding. You can configure your own logo, colors, and custom domain so the pages look like part of your product. See [Custom domain](/agentkit/advanced/custom-domain/).
## Detect when re-authentication is needed
[Section titled “Detect when re-authentication is needed”](#detect-when-re-authentication-is-needed)
A connected account can leave the `ACTIVE` state on its own, with no action from you or the user. When that happens, the next tool call fails until the user re-authorizes. To catch it early, subscribe to the `connected_account.status_updated` webhook instead of waiting for a failed call.
### Common causes
[Section titled “Common causes”](#common-causes)
OAuth connected accounts most often move to `EXPIRED` for reasons outside Scalekit’s control:
* **The provider revoked the refresh token.** A password change, an admin-initiated token revocation, or a provider security policy invalidates the refresh token, so Scalekit can no longer obtain new access tokens.
* **The refresh token expired.** Providers cap refresh-token lifetimes (for example, 30 or 180 days), and the expiry is rarely surfaced in advance.
* **No refresh token was issued.** When the connection’s scopes don’t request offline access, the provider returns only a short-lived access token and no refresh token to renew it.
* **The provider hit a per-user token limit.** Some providers keep only a fixed number of refresh tokens per user and app, and silently drop the oldest ones when a user reconnects repeatedly.
The first two cases require the user to re-authenticate; there is no server-side workaround. The last two are configuration issues you fix on the connection by requesting offline access scopes.
### Subscribe to status changes
[Section titled “Subscribe to status changes”](#subscribe-to-status-changes)
The `connected_account.status_updated` event fires on every status transition and carries both the new and previous status:
connected\_account.status\_updated
```json
1
{
2
"spec_version": "1",
3
"id": "evt_101652975398683158",
4
"type": "connected_account.status_updated",
5
"occurred_at": "2025-12-02T06:31:34.895815554Z",
6
"environment_id": "env_88640229614813449",
7
"object": "ConnectedAccount",
8
"data": {
9
"id": "ca_133400349586228019",
10
"identifier": "john@acmecorp.com",
11
"connection_id": "conn_133400101014995480",
12
"connection_name": "github-connect",
13
"provider": "GITHUB",
14
"authorization_type": "OAUTH",
15
"status": "EXPIRED",
16
"old_status": "ACTIVE"
17
}
18
}
```
Because the event covers every transition, filter on the change you care about. To alert users only when an active account needs re-authorization, act on `old_status` `ACTIVE` moving to `status` `EXPIRED`:
```js
1
// The event fires for all transitions (for example, PENDING_AUTH to ACTIVE).
2
// Filter to the one that requires user action, or you will notify on noise.
3
if (event.data.old_status === 'ACTIVE' && event.data.status === 'EXPIRED') {
4
// Generate a fresh authorization link and notify the user
5
}
```
When you receive this event, [generate a new authorization link](#handle-inactive-accounts) and prompt the user to reconnect. See the full payload for the [`connected_account.status_updated` event](/apis/#webhook/connectedaccountstatusupdated) in the API reference.
Verify webhook signatures
Scalekit signs every webhook. Verify the signature before you trust a payload, so a forged request cannot trigger a re-authorization prompt for the wrong user. See [Verify webhook signatures](/guides/webhooks-best-practices/#verify-webhook-signatures).
## List connected accounts
[Section titled “List connected accounts”](#list-connected-accounts)
Node.js only
List and delete operations are currently available in the Node.js SDK. Use the [Scalekit dashboard](https://app.scalekit.com) or REST API for Python.
```typescript
1
const listResponse = await actions.listConnectedAccounts({
2
connectionName: 'github-connect',
3
});
4
console.log('Connected accounts:', listResponse);
```
## Delete a connected account
[Section titled “Delete a connected account”](#delete-a-connected-account)
Deleting a connected account removes the user’s credentials and authorization state. The user must re-authenticate to reconnect.
```typescript
1
await actions.deleteConnectedAccount({
2
connectionName: 'github-connect',
3
identifier: 'user_123',
4
});
```
## Update OAuth scopes
[Section titled “Update OAuth scopes”](#update-oauth-scopes)
Scopes apply to OAuth connectors only. For non-OAuth connectors (API key, basic auth, and similar), generate a new authorization link and the hosted page will collect updated credentials.
To request additional OAuth scopes from an existing connected account:
1. Update the connection’s scopes in **AgentKit** > **Connections** > **Edit**.
2. Generate a new authorization link for the user.
3. The user completes the OAuth consent screen, approving the updated scopes.
4. Scalekit updates the connected account with the new token set.
---
# DOCUMENT BOUNDARY
---
# Configure a connection
> Set up a connection in the Scalekit Dashboard to authorize your agent to use a third-party connector on behalf of your users.
A **connection** is a configuration you create once in the Scalekit Dashboard. It holds everything Scalekit needs to interact with a connector’s API: OAuth app credentials, scopes, redirect URIs, and so on. One connection serves all your users.
Users don’t configure connections. When a user authenticates, Scalekit creates a **connected account**, the per-user record that links their identity to a connection and holds their tokens.
## What the connection form asks for
[Section titled “What the connection form asks for”](#what-the-connection-form-asks-for)
The connection form adapts to what the connector requires. Two things determine how much you need to configure:
* **OAuth-based connectors** require the most setup. You register an OAuth app with the provider, then enter those credentials into Scalekit.
* **Non-OAuth connectors** (API key, basic auth, key pairs, and similar) require minimal developer setup (usually just a name). The user provides their own credentials when they create their connected account.
The sections below walk through both patterns.
## Set up an OAuth connection
[Section titled “Set up an OAuth connection”](#set-up-an-oauth-connection)
OAuth connections require you to create an OAuth app with the provider and link it to Scalekit. Scalekit provides the Redirect URI; you bring the Client ID and Client Secret.
Already see pre-filled credentials? DCR handled registration for you.
For some connectors, Scalekit automatically completes **Dynamic Client Registration (DCR)** with the provider. If the **Client ID**, **Client Secret**, **OAuth Authorization URL**, and **Token Endpoint** fields are already filled in when you open the connection form, DCR was successful — skip steps 2–4 below and go directly to [configuring scopes](#configure-scopes).
If the fields are empty, the provider does not support DCR. Follow the manual registration steps below.
1. ### Open the connection form
[Section titled “Open the connection form”](#open-the-connection-form)
In the Scalekit Dashboard, go to **AgentKit** > **Connections** and click **Add connection**. Select the connector you want to configure.
The form shows the fields that connector requires.
2. ### Copy the redirect URI
[Section titled “Copy the redirect URI”](#copy-the-redirect-uri)
Scalekit generates a **Redirect URI** for this connection. Copy it; you’ll need it in the next step.
This URI is where the provider sends the user after they complete the OAuth consent screen. Scalekit handles the callback automatically.
Localhost redirect URIs work in staging
For development and staging, you can run your MCP server on `localhost`. Register a localhost URL (for example, `http://localhost:3000/callback`) as the redirect URI in the provider’s console. The MCP client must be able to reach the server — typically both run on the same machine during local testing.
3. ### Register your OAuth app with the provider
[Section titled “Register your OAuth app with the provider”](#register-your-oauth-app-with-the-provider)
In the provider’s developer console (GitHub, Salesforce, Google, etc.), create an OAuth app and add Scalekit’s Redirect URI to the list of authorized redirect URIs.
The provider will give you a **Client ID** and **Client Secret** after registration.
Redirect URI must match exactly
The URI in the provider’s console must match what Scalekit shows character-for-character, including trailing slashes. A mismatch causes the OAuth flow to fail with a redirect\_uri\_mismatch error.
4. ### Enter your credentials
[Section titled “Enter your credentials”](#enter-your-credentials)
Back in the Scalekit Dashboard, enter the **Client ID** and **Client Secret** from the provider.
5. ### Configure scopes
[Section titled “Configure scopes”](#configure-scopes)
Select the scopes your agent needs. Scopes define what your agent can do on the user’s behalf: for example, `read:email` or `repo`.
Scopes apply to all connected accounts
The scopes you set here apply to every connected account that uses this connection. If you need different scopes for different user groups, create separate connections for each group.
6. ### Save the connection
[Section titled “Save the connection”](#save-the-connection)
Click **Save**. The connection is now active and ready for connected accounts to be created against it.
Use Scalekit credentials to get started faster
For some connectors, Scalekit offers a **Use Scalekit credentials** option. This lets you skip the OAuth app registration step and start testing immediately. Switch to your own credentials before going to production. See [Bring your own credentials](/agentkit/advanced/bring-your-own-oauth/).
## Set up a non-OAuth connection
[Section titled “Set up a non-OAuth connection”](#set-up-a-non-oauth-connection)
For connectors that use API keys, basic auth, key pairs, or similar, the connection form asks for very little. In many cases, you only need to give the connection a name.
The user provides their own credentials (their API key, account details, or private key) when they create a connected account. Scalekit collects those credentials through the connected account form and stores them securely.
1. Go to **AgentKit** > **Connections** and click **Add connection**
2. Select the connector
3. Enter a **Connection name**: this identifies the connection in the dashboard and in your code
4. Click **Save**
When a connected account is created for this connection, Scalekit presents the user with a form that collects the credentials their specific account requires.
## Create multiple connections for the same connector
[Section titled “Create multiple connections for the same connector”](#create-multiple-connections-for-the-same-connector)
You can create more than one connection for the same connector. This is useful when:
* Different groups of users need different scopes
* You want to maintain separate OAuth apps for staging and production
* You’re integrating with multiple instances of the same service (for example, two different Salesforce orgs)
Each connection has its own name, which you use to identify it in API calls and in the dashboard.
## Common scenarios
[Section titled “Common scenarios”](#common-scenarios)
Why am I seeing a `failed_to_exchange_token` error after the consent screen?
This error means the OAuth token exchange failed after the user completed the provider’s consent screen. The `error_description` query parameter may include details such as `Error executing post auth hooks`.
**Common causes:** the verification session timed out because you waited too long on the consent screen, a transient failure during token exchange, a network interruption, or OAuth app misconfiguration (credentials or redirect URI).
**What to try:**
1. Close the error page and restart the connection flow
2. If the error persists, check the [Scalekit status page](https://status.scalekit.com) for ongoing incidents
3. If it still fails, contact [support](mailto:support@scalekit.com) with the full error URL (including `error` and `error_description` query parameters) and the timestamp
4. Verify OAuth app credentials and redirect URI configuration in the provider console
5. See [Troubleshoot connection errors](/agentkit/authentication/troubleshooting/) for redirect URI, scope, and provider-specific diagnosis steps
An error that resolves on retry is almost always transient. Consistent failures for the same connection warrant checking OAuth app configuration.
Why does the callback page say “session expired or invalid”?
The OAuth verification session has a time limit. If you take too long to complete authentication with the provider (for example, you step away mid-flow or the consent screen loads slowly), the session expires before the callback arrives.
Close the window and start the connection flow again. No configuration change is needed.
Why am I getting a `redirect_uri_mismatch` error?
The redirect URI registered in the provider’s OAuth app does not match the URI that Scalekit sends in the authorization request. Providers enforce an exact string match.
1. In Scalekit Dashboard, go to **AgentKit** > **Connections** and open the affected connection
2. Copy the **Redirect URI** shown in the connection form
3. In the provider’s developer console, verify the URI matches exactly — including protocol (`https` vs `http`), trailing slashes, and port numbers
4. Save and retry the connection flow
Common mismatches: a trailing slash (`/callback/` vs `/callback`), `http` vs `https`, or a missing port number.
---
# DOCUMENT BOUNDARY
---
# Manage encryption keys
> Protect data at rest with Scalekit managed keys or bring your own from a cloud Key Management Service (KMS).
Scalekit automatically protects data at rest with encryption keys. You can use Scalekit-managed keys or bring your own from a cloud Key Management Service (KMS) provider if your compliance policy requires you to own and control the root key. This guide shows you how to view and manage keys in the dashboard and set up Bring Your Own Key (BYOK) with GCP Cloud KMS.
## Key types
| Type | Description |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **Scalekit managed Data Encryption Key (DEK)** | Scalekit generates and manages the key. No setup required. |
| **Bring your own key (BYOK)** | You provide a key from your own KMS. You own the key lifecycle, including rotation and revocation. |
## Key states
| State | Description |
| ---------- | ----------------------------------------------------------------------------------------------- |
| Staged | Created, not yet in use. No data is encrypted with it. |
| Primary | The active key. All new encryption operations use it. |
| Deprecated | Replaced by a newer key. Existing records may still reference it until re-encryption completes. |
## Manage keys
Go to **Settings** and open the **Encryption keys** tab.
1. **Create a key**
Click **Create New Key** and select a provider:
* **Scalekit Managed DEK**: Scalekit generates and manages the key. Click **Create**.
* **BYOK - GCP Cloud KMS**: Complete [Set up BYOK with GCP KMS](#set-up-byok-with-gcp-kms) first to create the GCP key and grant access, then enter the key resource name here and click **Create**.
The key is created in **Staged** state.
2. **Activate the key**
Click **Activate** on a Staged key. The key becomes Primary and Scalekit uses it for all new encryption operations.
One primary key at a time
Creating a new key does not replace the current Primary key. The new key stays Staged until you explicitly activate it.
3. **Re-encrypt existing data**
To apply the new key to existing records, click **Re-encrypt Data**. Scalekit decrypts each existing record with the previous key and re-encrypts it with the new key.
## Set up BYOK with GCP KMS
Use BYOK to register an encryption key from Google Cloud KMS that your team owns and controls. Scalekit uses the KMS API for all encrypt and decrypt operations and never stores the key material.
You own the key lifecycle
BYOK gives you direct control over your encryption key. Scalekit cannot rotate or manage the GCP key on your behalf. If the key is disabled, destroyed, or Identity and Access Management (IAM) access is revoked, Scalekit cannot encrypt new data or decrypt existing records until you restore access.
### Prerequisites
* A **Google Cloud project** with the [Cloud KMS API enabled](https://cloud.google.com/kms/docs/create-encryption-keys)
* **IAM permissions** to create key rings, keys, and set key-level IAM policies
* The **Scalekit service account email**, shown in the Scalekit dashboard when you select BYOK
* `gcloud` CLI installed and authenticated, or access to the [GCP Console](https://console.cloud.google.com/security/kms)
1. **Create a key ring**
A [key ring](https://cloud.google.com/kms/docs/resource-hierarchy#key_rings) groups encryption keys by location. Key rings cannot be deleted or renamed once created. Choose the name and location carefully.
* gcloud CLI
```bash
1
gcloud kms keyrings create "scalekit-kms-keyring" \
2
--location "global" \
3
--project "YOUR-GCP-PROJECT"
```
Replace `YOUR-GCP-PROJECT` with your Google Cloud project ID.
* GCP Console
1. Open [**Security > Key management**](https://console.cloud.google.com/security/kms), click **Key rings**.
2. Click **Create key ring**, enter `scalekit-kms-keyring` as the name.
3. Set **Location** to **Global** and click **Create**.
Choose the right location
Use `global` if your Scalekit environment has no regional data-residency requirement. For region-specific compliance, choose a location such as `us-east1` or `europe-west1`. See [Cloud KMS locations](https://cloud.google.com/kms/docs/locations).
2. **Create an encryption key**
Create a symmetric AES-256-GCM key inside the key ring.
* gcloud CLI
```bash
1
gcloud kms keys create "scalekit-kms-key" \
2
--location "global" \
3
--keyring "scalekit-kms-keyring" \
4
--purpose "encryption" \
5
--project "YOUR-GCP-PROJECT"
```
* GCP Console
1. Click the key ring you created, then click **Create key**.
2. Enter `scalekit-kms-key` as the name.
3. Set **Protection level** to **HSM** (recommended) and click **Continue**.
4. Set **Key material** to **HSM-generated** and click **Continue**.
5. Set **Purpose** to **Symmetric encrypt/decrypt** and click **Continue**.
6. Set **Rotation period** per your policy and click **Create**.
3. **Grant Scalekit access to the key**
Grant both roles at the key level to limit Scalekit’s IAM access to this specific key.
| Role | Purpose |
| -------------------------------------------- | ----------------------------------------------- |
| `roles/cloudkms.cryptoKeyEncrypterDecrypter` | Encrypt and decrypt the DEK |
| `roles/cloudkms.viewer` | Read key metadata for health checks and listing |
* gcloud CLI
```bash
1
gcloud kms keys add-iam-policy-binding "scalekit-kms-key" \
2
--keyring "scalekit-kms-keyring" \
3
--location "global" \
4
--project "YOUR-GCP-PROJECT" \
5
--member "serviceAccount:SCALEKIT-SERVICE-ACCOUNT" \
6
--role "roles/cloudkms.cryptoKeyEncrypterDecrypter"
7
8
gcloud kms keys add-iam-policy-binding "scalekit-kms-key" \
9
--keyring "scalekit-kms-keyring" \
10
--location "global" \
11
--project "YOUR-GCP-PROJECT" \
12
--member "serviceAccount:SCALEKIT-SERVICE-ACCOUNT" \
13
--role "roles/cloudkms.viewer"
```
* GCP Console
1. In the GCP Console, open **Security > Key management**, click **Key rings**, then click the key ring name.
2. Click the key name, then open the **Permissions** tab.
3. Click **Grant access**. A side panel opens.
4. In the **New principals** field, enter the Scalekit service account email. Copy it from **Settings > Encryption keys > Create New Key > BYOK - GCP Cloud KMS** in the Scalekit dashboard.
5. In the **Assign Roles** section, select **Cloud KMS CryptoKey Encrypter/Decrypter** from the first **Role** dropdown.
6. Click **+ Add another role** and select **Cloud KMS Viewer**.
7. Click **Save**. The policy update takes effect within a few minutes.
Key-level vs project-level IAM
Grant these roles at the **key level** to limit Scalekit’s access to only this specific key.
4. **Register the key in Scalekit**
* In the Scalekit dashboard, go to **Settings** and open the **Encryption keys** tab.
* Click **Create New Key** and select **BYOK - GCP Cloud KMS**.
* Copy the Scalekit service account email shown in the modal. You need it for step 3 (grant Scalekit access to the key) if you have not granted IAM access yet.
* Enter the fully-qualified GCP key resource name:
```plaintext
1
projects/YOUR-GCP-PROJECT/locations/global/keyRings/scalekit-kms-keyring/cryptoKeys/scalekit-kms-key
```
To retrieve the exact name from the CLI:
```bash
1
gcloud kms keys describe "scalekit-kms-key" \
2
--keyring "scalekit-kms-keyring" \
3
--location "global" \
4
--project "YOUR-GCP-PROJECT" \
5
--format="value(name)"
```
* Click **Create**. The key is created in **Staged** state.
Key resource name format
The key reference must follow this format:
```plaintext
1
projects/{PROJECT_ID}/locations/{LOCATION}/keyRings/{KEYRING_NAME}/cryptoKeys/{KEY_NAME}
```
To retrieve the key ring path:
```bash
1
gcloud kms keyrings describe "scalekit-kms-keyring" \
2
--location "global" \
3
--project "YOUR-GCP-PROJECT" \
4
--format="value(name)"
```
Append `/cryptoKeys/scalekit-kms-key` to get the full key reference.
5. **Activate the key**
Click **Activate** on the staged key. The key becomes Primary and Scalekit uses it for all new encryption operations.
Activation is permanent
Once activated, you cannot deactivate the key without creating and activating a new key. Confirm your IAM grants are in place before activating.
6. **Re-encrypt existing data**
Activation covers new writes automatically. Click **Re-encrypt Data** to migrate existing records. Scalekit decrypts each record with the previous key and re-encrypts it with the new key.
## Monitor with audit logs
Cloud KMS logs every cryptographic operation to [Cloud Audit Logs](https://cloud.google.com/kms/docs/audit-logging). Use this filter in **Cloud Logging** to see all encrypt, decrypt, and key events for your key:
```plaintext
1
resource.type="cloudkms_cryptokey"
```
## Fix common errors
Permission denied when activating or using the key
Check that both IAM bindings are applied to the correct key:
```bash
1
gcloud kms keys get-iam-policy "scalekit-kms-key" \
2
--keyring "scalekit-kms-keyring" \
3
--location "global" \
4
--project "YOUR-GCP-PROJECT"
```
The output should list the Scalekit service account with both `roles/cloudkms.cryptoKeyEncrypterDecrypter` and `roles/cloudkms.viewer`. If the bindings are missing, repeat step 3.
Re-encryption reports unrecoverable rows
Unrecoverable rows are records that could not be re-encrypted, typically because the previous key version was disabled or destroyed before re-encryption completed.
Contact [Scalekit support](https://scalekit.com/contact) if you need help recovering affected records.
Key resource name is not accepted
Verify the format is exactly:
```plaintext
1
projects/PROJECT_ID/locations/LOCATION/keyRings/KEYRING_NAME/cryptoKeys/KEY_NAME
```
Run `gcloud kms keys describe` with `--format="value(name)"` to retrieve the exact string. Do not construct it manually.
---
# DOCUMENT BOUNDARY
---
# Inspect a user connection in the dashboard
> Find a specific connected account in the Scalekit dashboard and read its state when an agent fails a tool call.
When an agent suddenly cannot reach a user’s Gmail, Calendar, or GitHub, the cause is almost always the state of that user’s **connected account** rather than your code. The dashboard shows that state directly, which is faster than adding logging and redeploying.
## Find the connection
[Section titled “Find the connection”](#find-the-connection)
Go to **Dashboard > Connections** and select the connection the agent uses — the name you pass as `connection_name` in your code, such as `github-connect`.
Each connection lists the connected accounts created against it. One row exists per user identifier you have authorized.
One account per user, per connection
A connected account is scoped to a single connection. A user who has authorized both Gmail and GitHub has two connected accounts, and they can be in different states.
## Read the state
[Section titled “Read the state”](#read-the-state)
The row’s status is the diagnosis. Only `ACTIVE` accounts can serve tool calls.
| State | What it means | What to do |
| ---------------------- | --------------------------------------------------------------------------- | ------------------------------------------ |
| `ACTIVE` | Credentials are valid and tool calls will work | Look elsewhere — the connection is healthy |
| `EXPIRED` | The access token expired | Send the user a fresh authorization link |
| `PENDING_AUTH` | The user never finished authenticating, or re-authentication is in progress | Send or re-send the authorization link |
| `PENDING_VERIFICATION` | OAuth finished but identity verification has not | Have the user complete verification |
| `DISCONNECTED` | The account was disconnected manually | Send a fresh authorization link |
For what each state means in code, see [Manage connected accounts](/agentkit/connected-accounts/). For the failure modes behind each one, see [Troubleshoot connection errors](/agentkit/authentication/troubleshooting/).
## Recover a broken connection
[Section titled “Recover a broken connection”](#recover-a-broken-connection)
Every non-`ACTIVE` state is fixed the same way: generate a new authorization link and send it to the user. The link opens a Scalekit-hosted page that adapts to the connection’s auth type, so you do not branch on connector type in your own code.
Once the user completes it, the row returns to `ACTIVE` and tool calls resume.
## Check the connection itself
[Section titled “Check the connection itself”](#check-the-connection-itself)
If every account on a connection is failing rather than one, the problem is the connection, not the users. Confirm on the connection’s own page that its credentials are still valid — a rotated or expired OAuth client on the provider side takes down every account beneath it at once.
---
# DOCUMENT BOUNDARY
---
# Set up and connect a Virtual MCP server
> Create a Virtual MCP Server, verify user connections, mint session tokens, and connect your agent using bearer auth.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
Before creating a Virtual MCP Server, configure the connections you want to expose. Each `connection_name` you reference must already exist in **AgentKit > Connections**.
See [Configure a connection](/agentkit/connections/) if you haven’t done this yet.
## Create a Virtual MCP server
[Section titled “Create a Virtual MCP server”](#create-a-virtual-mcp-server)
Create the server once per agent role — not once per user. The response includes a static `mcp_server_url` you reuse for every user and every session.
```python
1
import os
2
from scalekit import ScalekitClient
3
from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping
4
5
scalekit_client = ScalekitClient(
6
env_url=os.environ["SCALEKIT_ENV_URL"],
7
client_id=os.environ["SCALEKIT_CLIENT_ID"],
8
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
9
)
10
11
vmcp_response = scalekit_client.actions.mcp.create_config(
12
name="email-calendar-agent",
13
connection_tool_mappings=[
14
McpConfigConnectionToolMapping(
15
connection_name="gmail",
16
tools=["gmail_fetch_mails"],
17
),
18
McpConfigConnectionToolMapping(
19
connection_name="googlecalendar",
20
tools=[
21
"googlecalendar_list_events",
22
"googlecalendar_create_event",
23
],
24
),
25
],
26
)
27
28
config_id = vmcp_response.config.id
29
mcp_server_url = vmcp_response.config.mcp_server_url
```
Save `config_id` and `mcp_server_url`. You pass these to every agent session.
**Selecting tools**: Each `McpConfigConnectionToolMapping` controls which tools from a connection appear on the server. Omit `tools` to expose all tools for that connection. To find available tool names, browse **AgentKit > Catalog** or open the connection from **AgentKit > Connections**.
## Connect an agent
[Section titled “Connect an agent”](#connect-an-agent)
Run these steps before each agent session.
1. ## Check that connections are active
[Section titled “Check that connections are active”](#check-that-connections-are-active)
Verify all connections are still active for this user before minting a token. OAuth credentials can expire or be revoked at any time.
```python
1
accounts_response = scalekit_client.actions.mcp.list_mcp_connected_accounts(
2
config_id=config_id,
3
identifier="user_123", # your app's unique identifier for this user
4
include_auth_link=True, # include re-auth URLs for any inactive connections
5
)
6
7
for account in accounts_response.connected_accounts:
8
if account.connected_account_status != "ACTIVE":
9
print(f"{account.connection_name} needs auth: {account.authentication_link}")
```
`identifier` is any string that uniquely identifies a user in your system — an email, user ID, or UUID. Use the same value consistently across all calls.
If any connection is not `"ACTIVE"`, surface the `authentication_link` to the user before proceeding. See [Authorize user connections](/agentkit/tools/authorize/).
2. ## Mint a session token
[Section titled “Mint a session token”](#mint-a-session-token)
Mint a fresh token before every agent run. Never reuse a token from a previous session.
```python
1
from datetime import timedelta
2
3
token_response = scalekit_client.actions.mcp.create_session_token(
4
mcp_config_id=config_id,
5
identifier="user_123",
6
expiry=timedelta(hours=1),
7
)
8
9
token = token_response.token
```
Set `expiry` longer than the expected agent run duration. For a task that typically takes 20 minutes, a 30-minute expiry is sufficient.
3. ## Pass the token to your agent
[Section titled “Pass the token to your agent”](#pass-the-token-to-your-agent)
Pass `mcp_server_url` and the session token to your agent framework using bearer auth.
```python
1
mcp_server = {
2
"url": mcp_server_url,
3
"headers": {"Authorization": f"Bearer {token}"},
4
}
```
How you register the MCP server depends on your framework. For a complete end-to-end example using Claude Managed Agents — including vault-based auth injection and response streaming — see [Claude Managed Agents](/agentkit/examples/claude-managed-agents/).
## Manage servers
[Section titled “Manage servers”](#manage-servers)
**List servers**
```python
1
configs = scalekit_client.actions.mcp.list_configs()
2
for config in configs.configs:
3
print(config.id, config.name, config.mcp_server_url)
4
5
# Filter by name
6
configs = scalekit_client.actions.mcp.list_configs(filter_name="email-calendar-agent")
```
**Update a server**
You can update `description` or `connection_tool_mappings` on an existing server. Only do this when no agent sessions are actively running — updating mid-session can cause tools to become unavailable. For significant changes, create a new server and swap the `mcp_server_url` in your agent definition so existing sessions complete cleanly.
```python
1
scalekit_client.actions.mcp.update_config(
2
config_id=config_id,
3
connection_tool_mappings=[
4
McpConfigConnectionToolMapping(
5
connection_name="gmail",
6
tools=["gmail_fetch_mails", "gmail_send_mail"],
7
),
8
McpConfigConnectionToolMapping(
9
connection_name="googlecalendar",
10
tools=["googlecalendar_create_event", "googlecalendar_list_events"],
11
),
12
],
13
)
```
**Delete a server**
```python
1
scalekit_client.actions.mcp.delete_config(config_id=config_id)
```
Deleting a server immediately invalidates the `mcp_server_url`. Any agent connected to that URL loses tool access. Confirm no active sessions are running before deleting.
---
# DOCUMENT BOUNDARY
---
# OpenClaw skill
> Connect OpenClaw agents to third-party services through Scalekit. Supports LinkedIn, Notion, Slack, Gmail, and 200+ connectors.
Use the Scalekit AgentKit skill for [OpenClaw](https://github.com/scalekit-inc/openclaw-skill) to let your AI agents execute actions on third-party services directly from conversations. Search LinkedIn, read Notion pages, send Slack messages, query Snowflake, and more, all through Scalekit Connect without storing tokens or API keys in your agent.
Security considerations for AI agents
Scalekit stores tokens and API keys securely with full audit logging. OpenClaw, like all AI agent frameworks, is vulnerable to prompt injection and other agent-level attacks. Follow security best practices to protect your instance.
When you ask Claude to interact with a third-party service, the skill:
* Finds the configured connector in Scalekit (e.g., [Gmail connection setup](/agentkit/connectors/gmail/)) and identifies which connection to use based on the requested action
* Checks if the connection is active. For OAuth connections, it generates a magic link for new authorizations. For API key connections, it provides Dashboard guidance for setup
* Retrieves available tools and their parameter schemas for the connector, determining what actions are possible
* Calls the right tool with the correct parameters and returns the result to your conversation
* If no tool exists for the action, routes the request through Scalekit’s HTTP proxy, making direct API calls on your behalf
Automatic auth flow detection
The skill automatically detects whether a connection uses OAuth or an API key and applies the correct auth flow. No configuration needed.
Your agent never stores tokens or API keys. Scalekit acts as a token vault, managing all OAuth tokens, API keys, and credentials. The skill retrieves only what it needs at runtime, scoped to the requesting user.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* [OpenClaw](https://openclaw.ai) installed and configured
* A Scalekit account with AgentKit enabled: [sign up at app.scalekit.com](https://app.scalekit.com)
* `python3` and `uv` available in your PATH
## Get started
[Section titled “Get started”](#get-started)
1. ## Install the skill
[Section titled “Install the skill”](#install-the-skill)
Install the skill from ClawHub:
```bash
clawhub install scalekit-agent-auth
```
2. ## Configure credentials
[Section titled “Configure credentials”](#configure-credentials)
Add your Scalekit credentials to `.env` in your project root:
.env
```bash
1
TOOL_CLIENT_ID=skc_your_client_id # Your Scalekit client ID
2
TOOL_CLIENT_SECRET=your_client_secret # Your Scalekit client secret
3
TOOL_ENV_URL=https://your-env.scalekit.cloud # Your Scalekit environment URL
4
TOOL_IDENTIFIER=your_default_user_identifier # Default user context for tool calls
```
| Parameter | Description |
| -------------------- | --------------------------------------------------- |
| `TOOL_CLIENT_ID` | Your Scalekit client ID Required |
| `TOOL_CLIENT_SECRET` | Your Scalekit client secret Required |
| `TOOL_ENV_URL` | Your Scalekit environment URL Required |
| `TOOL_IDENTIFIER` | Default user context for all tool calls Recommended |
Environment variable security
Never commit `.env` files to version control. Add `.env` to your `.gitignore` file to prevent accidental exposure of credentials.
3. ## Usage
[Section titled “Usage”](#usage)
* Gmail
```txt
You: Show me my latest unread emails
```
OpenClaw will automatically:
1. Look up the `GMAIL` connection
2. Verify it’s active (or generate a magic link to authorize if needed)
3. Fetch the `gmail_list_emails` tool schema
4. Return your latest unread emails
* Notion
```txt
You: Read my Notion page https://notion.so/My-Page-abc123
```
OpenClaw will:
1. Look up the `NOTION` connection
2. If not yet authorized, generate a magic link for you to complete OAuth
3. Fetch the `notion_page_get` tool schema
4. Return the page content
## Supported connectors
[Section titled “Supported connectors”](#supported-connectors)
Any connector configured in Scalekit works with the OpenClaw skill, including Notion, Slack, Gmail, Google Sheets, GitHub, Salesforce, HubSpot, Linear, Snowflake, Exa, HarvestAPI, and 200+ more.
[Browse connections](/agentkit/connectors/)See all supported connectors in the Scalekit dashboard
[ClawHub listing](https://clawhub.dev/skills/scalekit-agent-auth)Install scalekit-agent-auth from ClawHub
## Common scenarios
[Section titled “Common scenarios”](#common-scenarios)
How do I authorize a new connection?
When you request an action for a connection that isn’t yet authorized, the skill automatically generates a magic link. Click the link to complete OAuth authorization in your browser. After authorization, return to your OpenClaw conversation and retry the action.
For API key-based connections (like Snowflake), you’ll need to configure credentials directly in the Scalekit Dashboard under **Connections**.
How do I switch between different user contexts?
Set `TOOL_IDENTIFIER` in your `.env` file to define a default user context. All tool calls will execute with that user’s permissions and connected accounts.
To use a different user context for a specific conversation, you can override the identifier by setting it in your OpenClaw configuration or passing it as a parameter when invoking the skill.
Why am I seeing a “connection not found” error?
This error occurs when the skill cannot find a configured connection for the requested connector. Check the following:
1. **Verify the connection exists**: Go to **Dashboard > Connections** and confirm the connector is configured
2. **Check connection status**: Ensure the connection shows as “Active” in the dashboard
3. **Verify environment**: Confirm you’re using the correct `TOOL_ENV_URL` for your environment
How do I debug tool execution issues?
Enable debug logging in your OpenClaw configuration to see detailed information about tool calls:
```bash
TOOL_DEBUG=true
```
This logs the tool name, parameters, and response for each execution, helping you identify issues with parameter formatting or API responses.
---
# DOCUMENT BOUNDARY
---
# Node.js SDK
> Install and initialize the Scalekit Node.js SDK for AgentKit.
Install the Node.js SDK, create a `ScalekitClient`, then use the sidebar clients for AgentKit.
* **Connected accounts** (`scalekit.actions`) — connect end-user accounts and execute tools
* **Tool calling** (`scalekit.tools`) — raw tool definitions for custom adapters
* **Error handling** — typed exceptions for API failures
## Install
[Section titled “Install”](#install)
```bash
1
npm install @scalekit-sdk/node
```
## Initialize
[Section titled “Initialize”](#initialize)
```ts
import { ScalekitClient } from '@scalekit-sdk/node'
// Security: load credentials from environment variables — never hard-code secrets
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENVIRONMENT_URL!,
process.env.SCALEKIT_CLIENT_ID!,
process.env.SCALEKIT_CLIENT_SECRET!
)
```
## Next steps
[Section titled “Next steps”](#next-steps)
1. [Connected accounts](/agentkit/sdks/node/actions/) — authorization links, connected accounts, `executeTool`
2. [Tool calling](/agentkit/sdks/node/tools/) — list tools for custom adapters
3. [Error handling](/agentkit/sdks/node/errors/) — catch `ScalekitNotFoundException` and related types
---
# DOCUMENT BOUNDARY
---
# Connected accounts
> Connect accounts, start auth, and execute tools with scalekit.actions
`scalekit.actions` is the primary AgentKit client for connecting end-user accounts, starting OAuth, and executing tools on their behalf.
**Common path:** create or look up a connected account → get an authorization link → verify the user after redirect → run tools with `executeTool`.
For raw tool schemas used by custom adapters, see [Tool calling](/agentkit/sdks/node/tools/). For exception types, see [Error handling](/agentkit/sdks/node/errors/).
### verifyConnectedAccountUser
[Section titled “verifyConnectedAccountUser”](#verifyconnectedaccountuser)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#verifyConnectedAccountUser)asyncverifyConnectedAccountUser
Verify the connected account user after OAuth callback.
paramparamsobject
Required or common fields: `authRequestId`, `identifier`.
authRequestId, identifier
returnsVerifyConnectedAccountUserResponse
Post-verify redirect URL.
```typescript
// authRequestId: from the user-verify redirect query string
// identifier: same user identifier used when starting connect
await scalekit.actions.verifyConnectedAccountUser({
authRequestId: 'opaque-auth-request-id',
identifier: 'user@example.com',
});
```
### listConnectedAccounts
[Section titled “listConnectedAccounts”](#listconnectedaccounts)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#listConnectedAccounts)asynclistConnectedAccounts
List connected accounts with optional filters.
paramparamsobject
Common fields: `connectionName`, `identifier`, `provider`, `organizationId`, `userId`, `pageSize`, `pageToken`, `query`. Pass `connectionNames` (string array) to filter to connected accounts belonging to any of the listed connections (exact match, max 20).
connectionName, identifier, provider, organizationId, userId, pageSize, pageToken, query, connectionNames
returnsListConnectedAccountsResponse
Paginated connected accounts.
```typescript
// Optional filters: connectionName, identifier, provider, pageSize, pageToken
const response = await scalekit.actions.listConnectedAccounts({
connectionName: 'GMAIL',
identifier: 'user@example.com',
pageSize: 20,
});
// response.connectedAccounts, response.nextPageToken, response.totalSize
```
### executeTool
[Section titled “executeTool”](#executetool)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#executeTool)asyncexecuteTool
Execute a tool on behalf of a connected account.
paramparamsobject
Required or common fields: `toolName`, `toolInput`, `identifier`, `connectedAccountId`, `connector`, `organizationId`, `userId`.
toolName, toolInput, identifier, connectedAccountId, connector, organizationId, userId
returnsExecuteToolResponse
Tool result and execution ID.
```typescript
// actions.executeTool maps toolInput -> tools.executeTool params
const response = await scalekit.actions.executeTool({
toolName: 'gmail_fetch_mails',
toolInput: { max_results: 1 },
identifier: 'user@example.com',
connector: 'GMAIL',
});
// response.data, response.executionId
```
### getAuthorizationLink
[Section titled “getAuthorizationLink”](#getauthorizationlink)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#getAuthorizationLink)asyncgetAuthorizationLink
Get an authorization magic link for a connected account.
paramparamsobject
Required or common fields: `connectionName`, `identifier`, `connectedAccountId`, `organizationId`, `userId`, `state`, `userVerifyUrl`.
connectionName, identifier, connectedAccountId, organizationId, userId, state, userVerifyUrl
returnsGetMagicLinkForConnectedAccountResponse
Authorization magic link.
```typescript
// connectionName: app connection / connector name
// identifier: end-user identifier for the connected account
const response = await scalekit.actions.getAuthorizationLink({
connectionName: 'GMAIL',
identifier: 'user@example.com',
state: 'csrf-token-abc123',
userVerifyUrl: 'https://yourapp.com/auth/callback',
});
// response.link — redirect the user here
```
### listConnections
[Section titled “listConnections”](#listconnections)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#listConnections)asynclistConnections
List app-level connections with optional pagination and provider filtering.
paramparamsobject
Required or common fields: `pageSize`, `pageToken`, `provider`.
pageSize, pageToken, provider
returnsListAppConnectionsResult
Paginated results.
```typescript
// App-level AgentKit connections (not SSO org connections)
const { connections, nextPageToken, totalSize } =
await scalekit.actions.listConnections({
pageSize: 30,
provider: 'GMAIL', // optional; case-sensitive provider key
});
for (const conn of connections) {
console.log(conn.id, conn.connectionName, conn.status, conn.enabled);
}
```
### deleteConnectedAccount
[Section titled “deleteConnectedAccount”](#deleteconnectedaccount)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#deleteConnectedAccount)asyncdeleteConnectedAccount
Delete a connected account.
paramparamsobject
Required or common fields: `connectionName`, `identifier`, `connectedAccountId`, `organizationId`, `userId`.
connectionName, identifier, connectedAccountId, organizationId, userId
returnsDeleteConnectedAccountResponse
Empty on success.
```typescript
// Require connectedAccountId OR connectionName + identifier
await scalekit.actions.deleteConnectedAccount({
connectionName: 'GMAIL',
identifier: 'user@example.com',
});
```
### getConnectedAccount
[Section titled “getConnectedAccount”](#getconnectedaccount)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#getConnectedAccount)asyncgetConnectedAccount
Get connected account authorization details.
paramparamsobject
Required or common fields: `connectionName`, `identifier`, `connectedAccountId`, `organizationId`, `userId`.
connectionName, identifier, connectedAccountId, organizationId, userId
returnsGetConnectedAccountByIdentifierResponse
The response payload for this operation.
```typescript
// Returns authorization details (sensitive). Prefer details-only APIs when available.
const response = await scalekit.actions.getConnectedAccount({
connectionName: 'GMAIL',
identifier: 'user@example.com',
});
// response.connectedAccount
```
### createConnectedAccount
[Section titled “createConnectedAccount”](#createconnectedaccount)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#createConnectedAccount)asynccreateConnectedAccount
Create a new connected account.
paramparamsobject
Required or common fields: `connectionName`, `identifier`, `authorizationDetails`, `organizationId`, `userId`, `apiConfig`.
connectionName, identifier, authorizationDetails, organizationId, userId, apiConfig
returnsCreateConnectedAccountResponse
The created resource.
```typescript
// authorizationDetails required. Shape matches AuthorizationDetails (oauthToken | staticAuth).
// Tests build it with @bufbuild/protobuf create() + AuthorizationDetailsSchema / OauthTokenSchema
// from the generated connected_accounts protobuf module.
const response = await scalekit.actions.createConnectedAccount({
connectionName: 'GMAIL',
identifier: 'user@example.com',
authorizationDetails: {
details: {
case: 'oauthToken',
value: {
accessToken: process.env.PROVIDER_ACCESS_TOKEN!,
},
},
},
});
// response.connectedAccount
```
### getOrCreateConnectedAccount
[Section titled “getOrCreateConnectedAccount”](#getorcreateconnectedaccount)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#getOrCreateConnectedAccount)asyncgetOrCreateConnectedAccount
Get an existing connected account or create a new one if it doesn’t exist.
paramparamsobject
Required or common fields: `connectionName`, `identifier`, `authorizationDetails`, `organizationId`, `userId`, `apiConfig`.
connectionName, identifier, authorizationDetails, organizationId, userId, apiConfig
returnsCreateConnectedAccountResponse
The response payload for this operation.
```typescript
// Upsert: creates when missing; authorizationDetails optional
const response = await scalekit.actions.getOrCreateConnectedAccount({
connectionName: 'GMAIL',
identifier: 'user@example.com',
});
// response.connectedAccount
// Alias: scalekit.actions.upsertConnectedAccount(...)
```
### updateConnectedAccount
[Section titled “updateConnectedAccount”](#updateconnectedaccount)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#updateConnectedAccount)asyncupdateConnectedAccount
Update an existing connected account.
paramparamsobject
Required or common fields: `connectionName`, `identifier`, `authorizationDetails`, `organizationId`, `userId`, `connectedAccountId`, `apiConfig`.
connectionName, identifier, authorizationDetails, organizationId, userId, connectedAccountId, apiConfig
returnsUpdateConnectedAccountResponse
The updated resource.
```typescript
// Require connectedAccountId OR connectionName + identifier
const response = await scalekit.actions.updateConnectedAccount({
connectionName: 'GMAIL',
identifier: 'user@example.com',
apiConfig: {
version: 'v1.0',
domain: 'gmail.com',
},
});
// response.connectedAccount
```
### request
[Section titled “request”](#request)
classActionsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/actions.ts
[#](#request)asyncrequest
Make a proxied REST API call on behalf of a connected account.
paramparamsobject
timeoutMs: Per-call request timeout in ms.
connectionName, identifier, path, method, queryParams, body, formData, headers
returnsobject
AxiosResponse(any)
AxiosResponse(any)
```typescript
// Proxied HTTP: {envUrl}/proxy{path} with connection_name + identifier headers
const response = await scalekit.actions.request({
connectionName: 'GMAIL',
identifier: 'user@example.com',
path: '/gmail/v1/users/me/profile',
method: 'GET',
});
// AxiosResponse: response.status, response.data
```
---
# DOCUMENT BOUNDARY
---
# Error handling
> Catch Scalekit exceptions from AgentKit calls and handle not-found, auth, and server failures
AgentKit methods on `scalekit.actions` and `scalekit.tools` throw typed exceptions when the API returns an error. Catch the specific type first, then fall back to the base server exception.
## Catch exceptions
[Section titled “Catch exceptions”](#catch-exceptions)
```ts
import {
ScalekitNotFoundException,
ScalekitUnauthorizedException,
ScalekitForbiddenException,
ScalekitServerException,
} from '@scalekit-sdk/node'
try {
const account = await scalekit.actions.getConnectedAccount({
connectionName: 'gmail',
identifier: 'user@example.com',
})
} catch (err) {
if (err instanceof ScalekitNotFoundException) {
// No connected account yet — create one or send the user through OAuth
} else if (err instanceof ScalekitUnauthorizedException) {
// Invalid or expired client credentials / tokens
} else if (err instanceof ScalekitForbiddenException) {
// Caller is authenticated but not allowed for this resource
} else if (err instanceof ScalekitServerException) {
// Unexpected API or platform error — log status and code
console.error(err.message)
} else {
throw err
}
}
```
## Exception types
[Section titled “Exception types”](#exception-types)
| Exception | When it is raised | Typical response |
| ------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------- |
| `ScalekitNotFoundException` | Resource does not exist (connected account, tool, config) | Create the resource or return a clear not-found to the user |
| `ScalekitUnauthorizedException` | Missing or invalid credentials | Refresh tokens or fix client ID/secret |
| `ScalekitForbiddenException` | Authenticated but not permitted | Adjust scopes, org, or role |
| `ScalekitServerException` | Base class for Scalekit HTTP/API failures | Log, retry when safe, surface a generic error |
`ScalekitServerException` is the base type. Prefer checking subclasses first so not-found and auth failures get the right UX.
## Related
[Section titled “Related”](#related)
* [Connected accounts](/agentkit/sdks/node/actions/) — connect accounts and execute tools
* [Tool calling](/agentkit/sdks/node/tools/) — list tool definitions
* [Install](/agentkit/sdks/node/) — create the Scalekit client
---
# DOCUMENT BOUNDARY
---
# Tool calling
> List raw tool schemas for custom adapters
`scalekit.tools` returns raw tool schemas so you can build custom agent adapters instead of using `scalekit.actions.executeTool` directly.
Use this client when you need tool definitions (name, parameters, connector) for frameworks or your own executor. For connect + execute flows, prefer [Connected accounts](/agentkit/sdks/node/actions/). See [Error handling](/agentkit/sdks/node/errors/) for exceptions.
### listTools
[Section titled “listTools”](#listtools)
classToolsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/tools.ts
[#](#listTools)asynclistTools
Lists tools available in your workspace with optional filtering and pagination.
paramoptionsobject
Optional fields: `filter`, `pageSize`, `pageToken`.
filter, pageSize, pageToken
returnsListToolsResponse
Paginated tools.
```typescript
const res = await scalekit.tools.listTools({
pageSize: 50,
filter: { query: 'calendar' },
});
// res.tools, res.nextPageToken
```
### listScopedTools
[Section titled “listScopedTools”](#listscopedtools)
classToolsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/tools.ts
[#](#listScopedTools)asynclistScopedTools
Lists tools that are scoped to a specific connected account identifier.
paramidentifierstring
Connected account identifier to scope the tools list.
paramoptionsobject
Optional fields: `filter`, `pageSize`, `pageToken`.
filter, pageSize, pageToken
returnsListScopedToolsResponse
Paginated results.
```typescript
// options.filter is required
const res = await scalekit.tools.listScopedTools('user@example.com', {
filter: {
// providers, toolNames, connectionNames as needed
},
pageSize: 50,
});
```
### listAvailableTools
[Section titled “listAvailableTools”](#listavailabletools)
classToolsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/tools.ts
[#](#listAvailableTools)asynclistAvailableTools
Lists tools that are available for a specific connected account identifier.
paramidentifierstring
Connected account identifier to scope the available tools.
paramoptionsobject
Optional fields: `pageSize`, `pageToken`.
pageSize, pageToken
returnsListAvailableToolsResponse
Paginated results.
```typescript
const res = await scalekit.tools.listAvailableTools('user@example.com', {
pageSize: 50,
});
```
### executeTool
[Section titled “executeTool”](#executetool)
classToolsClienthttps\://github.com/scalekit-inc/scalekit-sdk-node/blob/main/src/tools.ts
[#](#executeTool)asyncexecuteTool
Executes a tool using credentials from a connected account.
paramparamsobject
Tool execution options.
toolName, identifier, params, connectedAccountId, connector, organizationId, userId
returnsExecuteToolResponse
Tool result and execution ID.
```typescript
// Low-level tools client (params, not toolInput)
await scalekit.tools.executeTool({
toolName: 'slack.chat.postMessage',
identifier: 'T0123456',
params: { channel: 'C0123', text: 'hi' },
});
```
---
# DOCUMENT BOUNDARY
---
# Python SDK
> Install and initialize the Scalekit Python SDK for AgentKit.
Install the Python SDK, create a `ScalekitClient`, then use the sidebar for AgentKit clients.
* **Connections** (`scalekit_client.connection`) — create, list, get, and update environment-level connections
* **Connected accounts** (`scalekit_client.actions`) — connect end-user accounts and execute tools
* **Tool calling** — raw tool definitions for custom adapters
* **MCP server**, **Frameworks**, **Request modifiers**, **Custom OAuth** — advanced surfaces
* **Error handling** — typed exceptions for API failures
## Install
[Section titled “Install”](#install)
**Requires Python 3.8 or later.** The public SDK on PyPI does not support Python 3.5–3.7. If you run a legacy Python environment, contact [support](mailto:support@scalekit.com) to discuss alternatives.
```bash
1
pip install scalekit-sdk-python
```
## Initialize
[Section titled “Initialize”](#initialize)
```python
import os
from scalekit import ScalekitClient
scalekit_client = ScalekitClient(
env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
actions = scalekit_client.actions
```
## Next steps
[Section titled “Next steps”](#next-steps)
* [Connections](/agentkit/sdks/python/connections/)
* [Connected accounts](/agentkit/sdks/python/actions/)
* [Tool calling](/agentkit/sdks/python/tools/)
* [MCP](/agentkit/sdks/python/mcp/)
* [Framework adapters](/agentkit/sdks/python/framework-adapters/)
* [Error handling](/agentkit/sdks/python/errors/)
---
# DOCUMENT BOUNDARY
---
# Connected accounts
> Connect accounts, start auth, and execute tools with scalekit.actions
`scalekit.actions` is the primary AgentKit client for connecting end-user accounts, starting OAuth, and executing tools on their behalf.
**Common path:** create or look up a connected account → get an authorization link → verify the user after redirect → run tools.
For raw tool schemas, see [Tool calling](/agentkit/sdks/python/tools/). For exceptions, see [Error handling](/agentkit/sdks/python/errors/).
### get\_authorization\_link
[Section titled “get\_authorization\_link”](#get_authorization_link)
classActionsClient
[#](#get_authorization_link)asyncget\_authorization\_link
Generates a time-limited OAuth magic link to authorize a user’s connection.
paramidentifierstr
User identifier (e.g. email)
paramconnection\_namestr
Connector slug (e.g. gmail)
paramconnected\_account\_idstr
Direct connected account ID (ca\_…)
paramstatestr
Opaque value passed through to the redirect URL
paramuser\_verify\_urlstr
App redirect URL for user verification
returnsMagicLinkResponse
The link.
```python
# connection_name + identifier (or connected_account_id)
response = scalekit_client.actions.get_authorization_link(
connection_name="GMAIL",
identifier="user@example.com",
)
# response.link, response.expiry
```
### verify\_connected\_account\_user
[Section titled “verify\_connected\_account\_user”](#verify_connected_account_user)
classActionsClient
[#](#verify_connected_account_user)asyncverify\_connected\_account\_user
Verifies the user after OAuth callback.
paramauth\_request\_idstr
Token from the redirect URL query params **Required.**
paramidentifierstr
Current user identifier **Required.**
returnsVerifyConnectedAccountUserResponse
Post-verify redirect URL.
```python
response = scalekit_client.actions.verify_connected_account_user(
auth_request_id="opaque-auth-request-id",
identifier="user@example.com",
)
```
### get\_or\_create\_connected\_account
[Section titled “get\_or\_create\_connected\_account”](#get_or_create_connected_account)
classActionsClient
[#](#get_or_create_connected_account)asyncget\_or\_create\_connected\_account
Fetches an existing connected account or creates one if none exists.
paramconnection\_namestr
Connector slug **Required.**
paramidentifierstr
User\s identifier **Required.**
paramauthorization\_detailsdict
OAuth token or static auth details
paramorganization\_idstr
Organization tenant ID.
paramuser\_idstr
Your app user ID.
paramapi\_configdict
Connector-specific options (for example scopes or static.
returnsCreateConnectedAccountResponse
The connected account.id.
```python
# authorization_details is optional
response = scalekit_client.actions.get_or_create_connected_account(
connection_name="GMAIL",
identifier="user@example.com",
)
# response.connected_account
# Alias: scalekit_client.actions.upsert_connected_account(...)
```
### get\_connected\_account
[Section titled “get\_connected\_account”](#get_connected_account)
classActionsClient
[#](#get_connected_account)asyncget\_connected\_account
Fetches auth details for a connected account.
paramconnection\_namestr
Connector slug.
paramidentifierstr
End-user or workspace identifier. Use with connection\_name.
paramconnected\_account\_idstr
Connected account ID (ca\_…) when resolving by ID instead.
returnsGetConnectedAccountAuthResponse
The connected account.id.
```python
# Includes auth credentials (sensitive)
response = scalekit_client.actions.get_connected_account(
connection_name="GMAIL",
identifier="user@example.com",
)
# response.connected_account
```
### get\_connected\_account\_details
[Section titled “get\_connected\_account\_details”](#get_connected_account_details)
classActionsClient
[#](#get_connected_account_details)asyncget\_connected\_account\_details
Fetches connected account metadata **without** auth credentials.
paramconnection\_namestr
Connector slug.
paramidentifierstr
End-user or workspace identifier. Use with connection\_name.
paramconnected\_account\_idstr
Connected account ID (ca\_…) when resolving by ID instead.
returnsGetConnectedAccountDetailsResponse
The connected account.id.
```python
# Metadata only — no access/refresh tokens
details = scalekit_client.actions.get_connected_account_details(
connection_name="gmail",
identifier="user@example.com",
)
print(details.connected_account.status)
```
### list\_connected\_accounts
[Section titled “list\_connected\_accounts”](#list_connected_accounts)
classActionsClient
[#](#list_connected_accounts)asynclist\_connected\_accounts
Runs `list_connected_accounts` and returns the result.
paramconnection\_namestr
Filter by a single connector slug
paramidentifierstr
Filter by user identifier
paramproviderstr
Filter by provider
paramconnection\_nameslist
Filter to connected accounts belonging to any of these connection names (exact match, max 20 names). Cannot be combined with `connection_name`.
returnsListConnectedAccountsResponse
Paginated connected accounts.
```python
result = scalekit_client.actions.list_connected_accounts(
connection_names=["GMAIL", "slack"],
identifier="user@example.com",
)
# result.connected_accounts, result.total_count, result.next_page_token
```
### create\_connected\_account
[Section titled “create\_connected\_account”](#create_connected_account)
classActionsClient
[#](#create_connected_account)asynccreate\_connected\_account
Creates a connected account with explicit auth details.
paramconnection\_namestr
Connector slug.
paramidentifierstr
End-user identifier.
paramauthorization\_detailsdict
Authorization details.
paramorganization\_idstr
Organization tenant ID.
paramuser\_idstr
Your app user ID.
paramapi\_configdict
Connector-specific options (for example scopes or static.
returnsCreateConnectedAccountResponse
CreateConnectedAccountResponse.
```python
response = scalekit_client.actions.create_connected_account(
connection_name="GMAIL",
identifier="user@example.com",
authorization_details={
"oauth_token": {
"access_token": "",
"refresh_token": "",
"scopes": [],
}
},
)
# response.connected_account
```
### update\_connected\_account
[Section titled “update\_connected\_account”](#update_connected_account)
classActionsClient
[#](#update_connected_account)asyncupdate\_connected\_account
Requires `connected_account_id` **or** `connection_name` + `identifier`.
paramconnection\_namestr
Connector slug.
paramidentifierstr
End-user or workspace identifier. Use with connection\_name.
paramconnected\_account\_idstr
Connected account ID (ca\_…) when updating by ID instead.
paramauthorization\_detailsdict
Replace or merge stored credentials (OAuth tokens, API.
paramorganization\_idstr
Organization tenant ID.
paramuser\_idstr
Your app user ID.
paramapi\_configdict
Connector-specific configuration to persist on the account
returnsUpdateConnectedAccountResponse
UpdateConnectedAccountResponse.
```python
response = scalekit_client.actions.update_connected_account(
connection_name="GMAIL",
identifier="user@example.com",
authorization_details={
"oauth_token": {
"access_token": "ya29...",
"refresh_token": "...",
"scopes": ["email"],
}
},
)
# response.connected_account
```
### delete\_connected\_account
[Section titled “delete\_connected\_account”](#delete_connected_account)
classActionsClient
[#](#delete_connected_account)asyncdelete\_connected\_account
Deletes a connected account and revokes its credentials.
paramconnection\_namestr
Connector slug.
paramidentifierstr
End-user or workspace identifier. Use with connection\_name.
paramconnected\_account\_idstr
Connected account ID (ca\_…) when deleting by ID instead.
returnsDeleteConnectedAccountResponse
DeleteConnectedAccountResponse.
```python
scalekit_client.actions.delete_connected_account(
connection_name="GMAIL",
identifier="user@example.com",
)
```
### execute\_tool
[Section titled “execute\_tool”](#execute_tool)
classActionsClient
[#](#execute_tool)asyncexecute\_tool
Executes a named tool via Scalekit.
paramtool\_namestr
Tool name (e.g. gmail\_fetch\_emails) **Required.**
paramtool\_inputdict
Parameters the tool expects **Required.**
paramidentifierstr
User\s identifier
paramconnected\_account\_idstr
Direct connected account ID
returnsExecuteToolResponse
Tool result and execution ID.
```python
# ActionClient: tool_input first, then tool_name
result = scalekit_client.actions.execute_tool(
tool_input={"max_results": 1},
tool_name="gmail_fetch_mails",
identifier="user@example.com",
)
# result.data, result.execution_id
```
### request
[Section titled “request”](#request)
classActionsClient
[#](#request)asyncrequest
Makes a REST API call on behalf of a connected account.
paramconnection\_namestr
Connector slug **Required.**
paramidentifierstr
User\s identifier **Required.**
parampathstr
API path (e.g. /gmail/v1/users/me/messages) **Required.**
parammethodstr
HTTP method. Default: GET
paramquery\_paramsdict
URL query parameters appended to path
parambodyany
JSON-serializable body for POST, PUT, PATCH, or similar.
paramform\_datadict
Multipart form fields when the upstream API expects form.
paramheadersdict
Extra HTTP headers merged with Scalekit-injected auth.
returnsrequests.Response
See return type `requests.Response`.
```python
# Proxied HTTP via {env_url}/proxy{path}
response = scalekit_client.actions.request(
connection_name="GMAIL",
identifier="user@example.com",
path="/gmail/v1/users/me/profile",
method="GET",
)
# requests.Response: response.status_code, response.json()
```
---
# DOCUMENT BOUNDARY
---
# Connections
> Manage environment-level AgentKit connections with scalekit_client.connection
`scalekit_client.connection` manages the environment-level connections that AgentKit connectors run on. A connection holds the OAuth app credentials, scopes, and redirect URI Scalekit uses when your users authorize a connector, and one connection serves every user in that environment.
**Common path:** create the connection once → attach OAuth credentials with `update_environment_connection` → connect end-user accounts with [`scalekit_client.actions`](/agentkit/sdks/python/actions/) → run tools.
Most teams create connections in the Scalekit Dashboard — see [Configure a connection](/agentkit/connections/). Use these methods when you provision or manage environments in code, such as seeding a new environment from a setup script or CI job.
These methods cover environment-level **app** connections (`Flags(is_app=True)`). Organization-scoped SSO connection APIs stay on the [SaaSKit connection reference](/saaskit/sdks/python/connection/).
### create\_environment\_connection
[Section titled “create\_environment\_connection”](#create_environment_connection)
clientConnectionhttps\://github.com/scalekit-inc/scalekit-sdk-python/blob/main/scalekit/connection.py
[#](#create_environment_connection)create\_environment\_connection
Creates a new environment-level connection. Pass `Flags(is_app=True)` to register it as an app connection that AgentKit connectors can use.
paramconnectionCreateConnection
CreateConnection object with the provider key and connection type.
paramflagsOptional\[Flags]
Optional. Connection flags (`is_login`, `is_app`).
returnsCreateConnectionResponse
Create Connection Response
create\_connection.py
```python
from scalekit.v1.connections.connections_pb2 import (
CreateConnection, ConnectionType, Flags
)
# Register the connection at the environment level. is_app=True marks it as an
# app connection, which is what AgentKit connectors resolve against.
response = scalekit_client.connection.create_environment_connection(
connection=CreateConnection(
provider_key='HUBSPOT',
type=ConnectionType.OAUTH,
),
flags=Flags(is_app=True),
)
connection = response[0].connection
print(f"Created: {connection.id}, Key: {connection.key_id}")
```
### list\_app\_connections
[Section titled “list\_app\_connections”](#list_app_connections)
clientConnectionhttps\://github.com/scalekit-inc/scalekit-sdk-python/blob/main/scalekit/connection.py
[#](#list_app_connections)list\_app\_connections
Lists environment-level app connections. Filter by provider or search by connection name (key ID) or provider.
parampage\_sizeOptional\[int]
Results per page (max 30).
parampage\_tokenOptional\[str]
Optional. Token for pagination.
paramproviderOptional\[str]
Optional. Filter by provider (e.g. `HUBSPOT`).
paramqueryOptional\[str]
Optional. Free-text search on connection name (key ID) or provider (3–100 characters).
returnsListAppConnectionsResponse
List App Connections Response
list\_connections.py
```python
response = scalekit_client.connection.list_app_connections()
for conn in response[0].connections:
print(f"Connection: {conn.id}, Provider: {conn.provider_key}")
# Filter by provider
response = scalekit_client.connection.list_app_connections(
provider='HUBSPOT',
page_size=10,
)
# Search by connection name or provider
response = scalekit_client.connection.list_app_connections(query='hubspot')
```
### get\_environment\_connection
[Section titled “get\_environment\_connection”](#get_environment_connection)
clientConnectionhttps\://github.com/scalekit-inc/scalekit-sdk-python/blob/main/scalekit/connection.py
[#](#get_environment_connection)get\_environment\_connection
Returns an environment-level connection by its id.
paramconnection\_idstr
Connection id to retrieve.
returnsGetConnectionResponse
Get Connection Response
get\_connection.py
```python
response = scalekit_client.connection.get_environment_connection('conn_123456')
conn = response[0].connection
print(f"Provider: {conn.provider_key}, Type: {conn.type}, Key: {conn.key_id}")
```
### update\_environment\_connection
[Section titled “update\_environment\_connection”](#update_environment_connection)
clientConnectionhttps\://github.com/scalekit-inc/scalekit-sdk-python/blob/main/scalekit/connection.py
[#](#update_environment_connection)update\_environment\_connection
Updates an environment-level connection. Use this after create to attach your own OAuth app credentials so users see your brand on the consent screen.
paramconnection\_idstr
Connection id to update.
paramconnectionUpdateConnection
UpdateConnection object with fields to update.
returnsUpdateConnectionResponse
Update Connection Response
update\_connection.py
```python
import os
from scalekit.v1.connections.connections_pb2 import (
UpdateConnection, ConnectionType, OAuthConnectionConfig
)
# Read credentials from the environment: a hard-coded client secret leaks to
# anyone with repository access and lets them impersonate your app with the
# provider.
response = scalekit_client.connection.update_environment_connection(
connection_id='conn_123456',
connection=UpdateConnection(
provider_key='HUBSPOT',
key_id='hubspot-key',
type=ConnectionType.OAUTH,
oauth_config=OAuthConnectionConfig(
client_id={'value': os.environ['HUBSPOT_CLIENT_ID']},
client_secret={'value': os.environ['HUBSPOT_CLIENT_SECRET']},
),
),
)
conn = response[0].connection
print(f"Updated: {conn.id}")
```
## Next steps
[Section titled “Next steps”](#next-steps)
* [Connected accounts](/agentkit/sdks/python/actions/) — connect end-user accounts and run tools on their behalf
* [Configure a connection](/agentkit/connections/) — create connections in the Scalekit Dashboard
---
# DOCUMENT BOUNDARY
---
# Custom OAuth
> AgentKit custom providers
`actions.providers` manages custom providers used with bring-your-own connectors. Methods take typed request objects and return typed responses.
Working end-to-end examples for OAuth, API key, bearer, and other auth types live in the [custom connectors demos](https://github.com/scalekit-inc/python-connect-demos/tree/main/custom-connectors) repo. Use those samples for field-complete setup rather than partial request shapes here.
See [Bring your own connector](/agentkit/bring-your-own-connector/overview/) for the product flow.
| Method | Purpose |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `actions.providers.create_custom_provider` | Create a custom provider (`CreateCustomProviderRequest`) |
| `actions.providers.update_custom_provider` | Partial update (`UpdateCustomProviderRequest`; only non-`None` fields apply) |
| `actions.providers.list_providers` | List or filter providers (`ListProvidersRequest`) |
| `actions.providers.delete_custom_provider` | Permanent delete by identifier (`DeleteCustomProviderRequest`) |
***
---
# DOCUMENT BOUNDARY
---
# Error handling
> Catch Scalekit exceptions from AgentKit calls and handle not-found, auth, and server failures
AgentKit methods throw typed exceptions when the API returns an error. Catch the specific type first, then fall back to the base server exception.
## Catch exceptions
[Section titled “Catch exceptions”](#catch-exceptions)
```python
from scalekit.common.exceptions import (
ScalekitNotFoundException,
ScalekitUnauthorizedException,
ScalekitForbiddenException,
ScalekitServerException,
)
try:
account = scalekit_client.actions.get_connected_account(
connection_name="gmail",
identifier="user@example.com",
)
except ScalekitNotFoundException:
# No connected account yet — create one or send the user through OAuth
pass
except ScalekitUnauthorizedException:
# Invalid or expired client credentials / tokens
pass
except ScalekitForbiddenException:
# Caller is authenticated but not allowed for this resource
pass
except ScalekitServerException as e:
# Unexpected API or platform error
print(e.error_code, e.http_status)
```
## Exception types
[Section titled “Exception types”](#exception-types)
| Exception | When it is raised | Typical response |
| ------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------- |
| `ScalekitNotFoundException` | Resource does not exist (connected account, tool, config) | Create the resource or return a clear not-found to the user |
| `ScalekitUnauthorizedException` | Missing or invalid credentials | Refresh tokens or fix client ID/secret |
| `ScalekitForbiddenException` | Authenticated but not permitted | Adjust scopes, org, or role |
| `ScalekitServerException` | Base class for Scalekit HTTP/API failures | Log, retry when safe, surface a generic error |
`ScalekitServerException` is the base type. Prefer checking subclasses first so not-found and auth failures get the right UX.
## Related
[Section titled “Related”](#related)
* [Connected accounts](/agentkit/sdks/python/actions/) — connect accounts and execute tools
* [Tool calling](/agentkit/sdks/python/tools/) — list tool definitions
* [Install](/agentkit/sdks/python/) — create the Scalekit client
---
# DOCUMENT BOUNDARY
---
# Frameworks
> Adapters for agent frameworks
Framework adapters map Scalekit tools into popular agent frameworks so you can register tools without hand-writing schema conversion.
Pick the adapter for your stack, pass the Scalekit client, and register tools on the agent. See [Connected accounts](/agentkit/sdks/python/actions/) for the underlying client and [Error handling](/agentkit/sdks/python/errors/) for exceptions.
### actions.langchain.get\_tools
[Section titled “actions.langchain.get\_tools”](#actionslangchainget_tools)
classLangChainhttps\://github.com/scalekit-inc/scalekit-sdk-python/blob/main/scalekit/actions/frameworks/langchain.py
[#](#get_tools)asyncget\_tools
List scoped tools for an identifier and return LangChain `StructuredTool` objects.
paramidentifierstr
Connected-account identifier to scope tools. **Required.**
paramprovidersOptional\[List\[str]]
Filter by provider names.
paramtool\_namesOptional\[List\[str]]
Filter by tool names.
paramconnection\_namesOptional\[List\[str]]
Filter by connection names.
parampage\_sizeOptional\[int]
Maximum tools per page.
parampage\_tokenOptional\[str]
Pagination token.
returnsList\[StructuredTool]
LangChain structured tools.
```python
tools = scalekit_client.actions.langchain.get_tools(
identifier="user@example.com",
connection_names=["GMAIL"],
page_size=50,
)
```
---
# DOCUMENT BOUNDARY
---
# MCP configurations
> Expose AgentKit tools over MCP.
Expose AgentKit tools over MCP so hosts like Claude Desktop or Cursor can call connected-account tools through a standard protocol.
Configure an MCP server, attach connected accounts, and issue tokens for clients. See [Error handling](/agentkit/sdks/python/errors/) for API exceptions.
### scalekit\_client.actions.mcp.create\_config
[Section titled “scalekit\_client.actions.mcp.create\_config”](#scalekit_clientactionsmcpcreate_config)
classMcpClient
[#](#actions.mcp.create_config)asyncactions.mcp.create\_config
Runs `scalekit_client.actions.mcp.create_config` and returns the result.
paramnamestr
Config name **Required.**
paramdescriptionstr
Human-readable summary of what this MCP config exposes
paramconnection\_tool\_mappingslist
List of McpConfigConnectionToolMapping objects
returnsCreateMcpConfigResponse
Create mcp config.
```python
response = scalekit_client.actions.mcp.create_config(
name="My agent tools",
description="Connectors exposed to the agent",
)
# response.config
```
### scalekit\_client.actions.mcp.list\_configs
[Section titled “scalekit\_client.actions.mcp.list\_configs”](#scalekit_clientactionsmcplist_configs)
classMcpClient
[#](#actions.mcp.list_configs)asyncactions.mcp.list\_configs
Runs `scalekit_client.actions.mcp.list_configs` and returns the result.
parampage\_sizeint
Maximum configs per page (server default if omitted)
parampage\_tokenstr
Opaque cursor from a previous list response
paramfilter\_idstr
Filter by config ID
paramfilter\_namestr
Filter by exact name
paramfilter\_providerstr
Filter by provider slug
paramfilter\_mcp\_server\_urlstr
Filter by MCP server URL
paramsearchstr
Free-text search on name
returnsListMcpConfigsResponse
ListMcpConfigsResponse.
```python
page1 = scalekit_client.actions.mcp.list_configs(page_size=10)
for cfg in page1.configs:
print(cfg.name, cfg.mcp_server_url)
```
### scalekit\_client.actions.mcp.update\_config
[Section titled “scalekit\_client.actions.mcp.update\_config”](#scalekit_clientactionsmcpupdate_config)
classMcpClient
[#](#actions.mcp.update_config)asyncactions.mcp.update\_config
Runs `scalekit_client.actions.mcp.update_config` and returns the result.
paramconfig\_idstr
MCP config ID from create\_config or list\_configs.
paramdescriptionstr
New human-readable description for this config
paramconnection\_tool\_mappingslist
Replaces existing mappings
returnsUpdateMcpConfigResponse
UpdateMcpConfigResponse.
```python
response = scalekit_client.actions.mcp.update_config(
config_id="cfg_01abc123",
description="Updated description",
)
```
### scalekit\_client.actions.mcp.delete\_config
[Section titled “scalekit\_client.actions.mcp.delete\_config”](#scalekit_clientactionsmcpdelete_config)
classMcpClient
[#](#actions.mcp.delete_config)asyncactions.mcp.delete\_config
Runs `scalekit_client.actions.mcp.delete_config` and returns the result.
paramconfig\_idstr
MCP config ID to delete **Required.**
returnsDeleteMcpConfigResponse
DeleteMcpConfigResponse.
```python
scalekit_client.actions.mcp.delete_config(config_id="cfg_01abc123")
```
### scalekit\_client.actions.mcp.list\_mcp\_connected\_accounts
[Section titled “scalekit\_client.actions.mcp.list\_mcp\_connected\_accounts”](#scalekit_clientactionsmcplist_mcp_connected_accounts)
classMcpClient
[#](#actions.mcp.list_mcp_connected_accounts)asyncactions.mcp.list\_mcp\_connected\_accounts
Runs `scalekit_client.actions.mcp.list_mcp_connected_accounts` and returns the result.
paramconfig\_idstr
Virtual MCP config ID.
paramidentifierstr
User identifier **Required.**
paraminclude\_auth\_linkbool
Include auth/re-auth URLs.
returnsListMcpConnectedAccountsResponse
List mcp connected accounts.
```python
state = scalekit_client.actions.mcp.list_mcp_connected_accounts(
config_id="cfg_01abc123",
identifier="alice@example.com",
include_auth_link=True,
)
for account in state.connected_accounts:
if account.connected_account_status != "active":
print(account.connection_name, account.authentication_link)
```
### scalekit\_client.actions.mcp.create\_session\_token
[Section titled “scalekit\_client.actions.mcp.create\_session\_token”](#scalekit_clientactionsmcpcreate_session_token)
classMcpClient
[#](#actions.mcp.create_session_token)asyncactions.mcp.create\_session\_token
Mints a short-lived session token scoped to a user and a Virtual MCP Server config.
parammcp\_config\_idstr
Virtual MCP Server config ID **Required.**
paramidentifierstr
User identifier **Required.**
paramexpirytimedelta
Token lifetime (server default if omitted, typically 1 hour).
returnsCreateMcpSessionTokenResponse
Tokens and claims.
```python
from datetime import timedelta
resp = scalekit_client.actions.mcp.create_session_token(
mcp_config_id="cfg_01abc123",
identifier="alice@example.com",
expiry=timedelta(hours=1),
)
headers = {"Authorization": f"Bearer {resp.token}"}
```
---
# DOCUMENT BOUNDARY
---
# Request modifiers
> AgentKit modifiers
Modifiers intercept tool calls to transform inputs or outputs, useful for validation, enrichment, or logging.
```python
1
# actions comes from the Scalekit client (or your framework adapter)
2
actions = scalekit_client.actions
3
4
@actions.pre_modifier(tool_names=["gmail_fetch_emails"])
5
def add_default_label(tool_input):
6
tool_input.setdefault("label", "UNREAD")
7
return tool_input
8
9
@actions.post_modifier(tool_names=["gmail_fetch_emails"])
10
def filter_attachments(tool_output):
11
tool_output["emails"] = [e for e in tool_output["emails"] if not e.get("has_attachment")]
12
return tool_output
```
| Decorator | Receives | Returns |
| ------------------------------------ | -------- | --------------- |
| `@actions.pre_modifier(tool_names)` | `dict` | Modified `dict` |
| `@actions.post_modifier(tool_names)` | `dict` | Modified `dict` |
`tool_names` accepts a string or a list of strings. Multiple modifiers for the same tool chain in registration order.
***
---
# DOCUMENT BOUNDARY
---
# Tool calling
> List raw tool schemas for custom adapters
`scalekit.tools` returns raw tool schemas for custom agent adapters.
Use this when you need tool definitions for frameworks or your own executor. For connect + execute, use [Connected accounts](/agentkit/sdks/python/actions/). See [Error handling](/agentkit/sdks/python/errors/).
### tools.list\_tools
[Section titled “tools.list\_tools”](#toolslist_tools)
classToolsClient
[#](#tools.list_tools)asynctools.list\_tools
Runs `tools.list_tools` and returns the result.
paramfilterFilter
Filter by provider, identifier, or tool name
parampage\_sizeint
Maximum tools per page.
parampage\_tokenstr
Opaque cursor from a previous list response
returnsListToolsResponse
List tools.
```python
from scalekit.v1.tools.tools_pb2 import Filter
response = scalekit_client.tools.list_tools(
filter=Filter(query="calendar"),
page_size=50,
)
```
### tools.list\_scoped\_tools
[Section titled “tools.list\_scoped\_tools”](#toolslist_scoped_tools)
classToolsClient
[#](#tools.list_scoped_tools)asynctools.list\_scoped\_tools
Lists tools scoped to a specific user.
paramidentifierstr
User connected account identifier **Required.**
paramfilterScopedToolFilter
Filter by providers, tool names, or connection names
parampage\_sizeint
Maximum tools per page.
parampage\_tokenstr
Opaque cursor from a previous list response
returnsListScopedToolsResponse
List scoped tools.
```python
from scalekit.v1.tools.tools_pb2 import ScopedToolFilter
response = scalekit_client.tools.list_scoped_tools(
"user@example.com",
filter=ScopedToolFilter(),
)
```
### tools.execute\_tool
[Section titled “tools.execute\_tool”](#toolsexecute_tool)
classToolsClient
[#](#tools.execute_tool)asynctools.execute\_tool
Low-level tool execution.
paramtool\_namestr
Registered tool name to execute **Required.**
paramidentifierstr
End-user identifier.
paramparamsdict
Tool arguments matching the tool input schema
paramconnected\_account\_idstr
Connected account ID (ca\_…) when you already know it
returnsExecuteToolResponse
ExecuteToolResponse.
```python
# Low-level ToolsClient (tool_name, identifier, params)
response = scalekit_client.tools.execute_tool(
tool_name="gmail.messages.list",
identifier="user@example.com",
params={"maxResults": 10},
)
```
---
# DOCUMENT BOUNDARY
---
# Authorize a user
> Generate an authorization link, send it to your user, and confirm their connected account is active before your agent executes tools.
Once a connection is configured, your users need to grant your agent access to their account. This happens once per user per connection. Scalekit stores their tokens and keeps them fresh automatically.
The flow is:
1. Create a connected account for the user
2. Generate an authorization link and send it to the user
3. The user completes the OAuth consent screen
4. The connected account becomes `ACTIVE`. Your agent can now execute tools.
## Create a connected account and generate a link
[Section titled “Create a connected account and generate a link”](#create-a-connected-account-and-generate-a-link)
* Python
```python
1
# Create or retrieve the connected account for this user
2
response = actions.get_or_create_connected_account(
3
connection_name="github-connect",
4
identifier="user_123" # your app's unique user ID
5
)
6
connected_account = response.connected_account
7
8
# Generate the authorization link if the account is not yet active
9
if connected_account.status != "ACTIVE":
10
link_response = actions.get_authorization_link(
11
connection_name="github-connect",
12
identifier="user_123"
13
)
14
auth_url = link_response.link
15
# Redirect or send auth_url to the user
```
* Node.js
```typescript
1
import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb';
2
3
// Create or retrieve the connected account for this user
4
const response = await actions.getOrCreateConnectedAccount({
5
connectionName: 'github-connect',
6
identifier: 'user_123', // your app's unique user ID
7
});
8
9
const connectedAccount = response.connectedAccount;
10
11
// Generate the authorization link if the account is not yet active
12
if (connectedAccount?.status !== ConnectorStatus.ACTIVE) {
13
const linkResponse = await actions.getAuthorizationLink({
14
connectionName: 'github-connect',
15
identifier: 'user_123',
16
});
17
const authUrl = linkResponse.link;
18
// Redirect or send authUrl to the user
19
}
```
## Send the link to the user
[Section titled “Send the link to the user”](#send-the-link-to-the-user)
How you deliver the link depends on your application:
* **Web app:** redirect the user to `auth_url` directly if they’re in an active browser session
* **Email or notification:** send the link when the user isn’t actively in your app, or when connecting at their own pace is acceptable
* **In-app prompt:** show a button (“Connect GitHub”) when you want to prompt connection at a specific moment in the user’s workflow
Once the user opens the link and approves the OAuth consent screen, Scalekit exchanges the authorization code for tokens and marks the connected account `ACTIVE`. You do not need to handle the OAuth callback yourself.
Production: add user verification
By default, any user who completes the OAuth flow activates the connected account. In production, verify that the authorizing user matches the user your app intended to connect. See [Verify user identity](/agentkit/user-verification/).
## Check status and re-authorize
[Section titled “Check status and re-authorize”](#check-status-and-re-authorize)
Check the connected account status before executing tools. Tokens can expire or be revoked, so generate a new authorization link using the same flow when that happens.
* Python
```python
1
response = actions.get_or_create_connected_account(
2
connection_name="github-connect",
3
identifier="user_123"
4
)
5
connected_account = response.connected_account
6
# ACTIVE: ready for tool calls
7
# PENDING: user has not completed the OAuth flow
8
# EXPIRED: tokens expired, re-authorization required
9
# REVOKED: user revoked access from the provider
10
11
if connected_account.status != "ACTIVE":
12
link_response = actions.get_authorization_link(
13
connection_name="github-connect",
14
identifier="user_123"
15
)
16
# Redirect or send link_response.link to the user
```
* Node.js
```typescript
1
import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb';
2
3
const response = await actions.getOrCreateConnectedAccount({
4
connectionName: 'github-connect',
5
identifier: 'user_123',
6
});
7
8
const connectedAccount = response.connectedAccount;
9
// ACTIVE: ready for tool calls
10
// PENDING: user has not completed the OAuth flow
11
// EXPIRED: tokens expired, re-authorization required
12
// REVOKED: user revoked access from the provider
13
14
if (connectedAccount?.status !== ConnectorStatus.ACTIVE) {
15
const linkResponse = await actions.getAuthorizationLink({
16
connectionName: 'github-connect',
17
identifier: 'user_123',
18
});
19
// Redirect or send linkResponse.link to the user
20
}
```
---
# DOCUMENT BOUNDARY
---
# Pre and Post Processors
> Learn how to create pre and post processor workflows that are run before or after tool execution with Agent Auth.
Custom pre and post processors are a way to create custom workflows that are run before or after tool execution with Agent Auth. They are useful for:
* Validating and transforming input data
* Processing and Formatting output data
* Adding additional context to the tool execution
## Usage
[Section titled “Usage”](#usage)
---
# DOCUMENT BOUNDARY
---
# Custom tools
> Build tools that Scalekit does not provide out of the box by proxying provider API calls through connected accounts.
When you need a connector tool that Scalekit doesn’t offer as a pre-built tool, use **API Proxy mode**. You define the tool contract and call the provider endpoint through `actions.request`. Scalekit injects the user’s credentials from their connected account; your agent never handles raw tokens.
| Option | Best for | Who defines tool schema |
| ------------------------ | --------------------------------- | ----------------------- |
| Scalekit optimized tools | Common connector tools | Scalekit |
| Custom tools (API Proxy) | Unsupported or app-specific tools | Your application |
This page assumes the user has an `ACTIVE` connected account. If not, see [Authorize a user](/agentkit/tools/authorize/).
## Find the right endpoint
[Section titled “Find the right endpoint”](#find-the-right-endpoint)
The `path` you pass to `actions.request` is forwarded directly to the provider’s API; Scalekit only adds authentication headers. Look up the provider’s API reference to get the correct path, method, and request shape.
| Connector | API reference |
| ---------- | ------------------------------------------------------------------------------------------------ |
| Gmail | [Google Gmail API](https://developers.google.com/gmail/api/reference/rest) |
| Slack | [Slack API methods](https://api.slack.com/methods) |
| GitHub | [GitHub REST API](https://docs.github.com/en/rest) |
| Salesforce | [Salesforce REST API](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) |
| HubSpot | [HubSpot API](https://developers.hubspot.com/docs/api/overview) |
Base URL is managed by Scalekit
Provide only the path; Scalekit resolves the correct base URL for the connector and injects the user’s credentials automatically.
## Define your tool contract
[Section titled “Define your tool contract”](#define-your-tool-contract)
Design the tool around your agent’s intent, not the provider’s API surface. For example, to list Gmail filters:
* **Tool name:** `gmail_list_filters` (describes the action, not the endpoint)
* **Input:** `identifier` (your app’s user ID)
* **Output:** `{ filters: [...], count: N }` (structured, not the raw Gmail response)
Keep schemas focused on what the model needs. Strip provider-specific noise before returning data.
## Proxy the API call
[Section titled “Proxy the API call”](#proxy-the-api-call)
Use `actions.request` to call any provider endpoint. Scalekit handles credential injection.
**GET requests:** pass query parameters as a dict:
* Python
```python
1
def gmail_list_filters(identifier: str):
2
response = actions.request(
3
connection_name="gmail",
4
identifier=identifier,
5
method="GET",
6
path="/gmail/v1/users/me/settings/filters",
7
)
8
data = response.json()
9
return {"filters": data.get("filter", []), "count": len(data.get("filter", []))}
10
11
def gmail_list_unread(identifier: str, max_results: int = 10):
12
response = actions.request(
13
connection_name="gmail",
14
identifier=identifier,
15
method="GET",
16
path="/gmail/v1/users/me/messages",
17
query_params={"q": "is:unread", "maxResults": max_results},
18
)
19
return {"messages": response.json().get("messages", [])}
```
* Node.js
```typescript
1
async function gmailListFilters(identifier: string) {
2
const response = await scalekit.actions.request({
3
connectionName: 'gmail',
4
identifier,
5
method: 'GET',
6
path: '/gmail/v1/users/me/settings/filters',
7
});
8
const filters = response.data?.filter ?? [];
9
return { filters, count: filters.length };
10
}
11
12
async function gmailListUnread(identifier: string, maxResults = 10) {
13
const response = await scalekit.actions.request({
14
connectionName: 'gmail',
15
identifier,
16
method: 'GET',
17
path: '/gmail/v1/users/me/messages',
18
queryParams: { q: 'is:unread', maxResults },
19
});
20
return { messages: response.data?.messages ?? [] };
21
}
```
**POST requests:** pass a body for write operations:
* Python
```python
1
def slack_send_message(identifier: str, channel: str, text: str):
2
response = actions.request(
3
connection_name="slack",
4
identifier=identifier,
5
method="POST",
6
path="/api/chat.postMessage",
7
body={"channel": channel, "text": text},
8
)
9
data = response.json()
10
if not data.get("ok"):
11
raise ValueError(f"Slack error: {data.get('error')}")
12
return {"ts": data.get("ts"), "channel": data.get("channel")}
```
* Node.js
```typescript
1
async function slackSendMessage(identifier: string, channel: string, text: string) {
2
const response = await scalekit.actions.request({
3
connectionName: 'slack',
4
identifier,
5
method: 'POST',
6
path: '/api/chat.postMessage',
7
body: { channel, text },
8
});
9
if (!response.data?.ok) throw new Error(`Slack error: ${response.data?.error}`);
10
return { ts: response.data.ts, channel: response.data.channel };
11
}
```
## Check authorization before proxy calls
[Section titled “Check authorization before proxy calls”](#check-authorization-before-proxy-calls)
Verify the connected account is `ACTIVE` before making a proxy call and handle provider errors explicitly:
* Python
```python
1
account = actions.get_or_create_connected_account(
2
connection_name="gmail",
3
identifier=identifier,
4
).connected_account
5
6
if account.status != "ACTIVE":
7
raise ValueError("Connected account is not ACTIVE. Re-authorize the user.")
```
* Node.js
```typescript
1
import { ConnectorStatus } from '@scalekit-sdk/node/lib/pkg/grpc/scalekit/v1/connected_accounts/connected_accounts_pb';
2
3
const account = (await scalekit.actions.getOrCreateConnectedAccount({
4
connectionName: 'gmail',
5
identifier,
6
})).connectedAccount;
7
8
if (account?.status !== ConnectorStatus.ACTIVE) {
9
throw new Error('Connected account is not ACTIVE. Re-authorize the user.');
10
}
```
## Best practices
[Section titled “Best practices”](#best-practices)
* Expose only the fields your model needs; keep schemas small
* Validate inputs server-side; never trust model-generated parameters
* Use predictable JSON keys; return stable output across calls
* Map provider errors to clear tool errors; don’t leak raw provider payloads to prompts
---
# DOCUMENT BOUNDARY
---
# Proxy Tools
> Learn how to make direct API calls to providers using Agent Auth's proxy tools.
Custom tool definitions allow you to create specialized tools tailored to your specific business needs. You can combine multiple provider tools, add custom logic, and create reusable workflows that go beyond standard tool functionality.
## What are custom tools?
[Section titled “What are custom tools?”](#what-are-custom-tools)
Custom tools are user-defined functions that:
* **Extend existing tools**: Build on top of standard provider tools
* **Combine multiple operations**: Create workflows that use multiple tools
* **Add business logic**: Include custom validation, processing, and formatting
* **Create reusable patterns**: Standardize common operations across your team
* **Integrate with external systems**: Connect to your own APIs and services
## Custom tool structure
[Section titled “Custom tool structure”](#custom-tool-structure)
Every custom tool follows a standardized structure:
```javascript
1
{
2
name: 'custom_tool_name',
3
display_name: 'Custom Tool Display Name',
4
description: 'Description of what the tool does',
5
category: 'custom',
6
provider: 'custom',
7
input_schema: {
8
type: 'object',
9
properties: {
10
// Define input parameters
11
},
12
required: ['required_param']
13
},
14
output_schema: {
15
type: 'object',
16
properties: {
17
// Define output format
18
}
19
},
20
implementation: async (parameters, context) => {
21
// Custom tool logic
22
return result;
23
}
24
}
```
## Creating custom tools
[Section titled “Creating custom tools”](#creating-custom-tools)
### Basic custom tool
[Section titled “Basic custom tool”](#basic-custom-tool)
Here’s a simple custom tool that sends a welcome email:
```javascript
1
const sendWelcomeEmail = {
2
name: 'send_welcome_email',
3
display_name: 'Send Welcome Email',
4
description: 'Send a personalized welcome email to new users',
5
category: 'communication',
6
provider: 'custom',
7
input_schema: {
8
type: 'object',
9
properties: {
10
user_name: {
11
type: 'string',
12
description: 'Name of the new user'
13
},
14
user_email: {
15
type: 'string',
16
format: 'email',
17
description: 'Email address of the new user'
18
},
19
company_name: {
20
type: 'string',
21
description: 'Name of the company'
22
}
23
},
24
required: ['user_name', 'user_email', 'company_name']
25
},
26
output_schema: {
27
type: 'object',
28
properties: {
29
message_id: {
30
type: 'string',
31
description: 'ID of the sent email'
32
},
33
status: {
34
type: 'string',
35
enum: ['sent', 'failed'],
36
description: 'Status of the email'
37
}
38
}
39
},
40
implementation: async (parameters, context) => {
41
const { user_name, user_email, company_name } = parameters;
42
43
// Generate personalized email content
44
const emailBody = `
45
Welcome to ${company_name}, ${user_name}!
46
47
We're excited to have you join our team. Here are some next steps:
48
49
1. Complete your profile setup
50
2. Join our Slack workspace
51
3. Schedule a meeting with your manager
52
53
If you have any questions, don't hesitate to reach out!
54
55
Best regards,
56
The ${company_name} Team
57
`;
58
59
// Send email using standard email tool
60
const result = await context.tools.execute({
61
tool: 'send_email',
62
parameters: {
63
to: [user_email],
64
subject: `Welcome to ${company_name}!`,
65
body: emailBody
66
}
67
});
68
69
return {
70
message_id: result.message_id,
71
status: result.status === 'sent' ? 'sent' : 'failed'
72
};
73
}
74
};
```
### Multi-step workflow tool
[Section titled “Multi-step workflow tool”](#multi-step-workflow-tool)
Create a tool that combines multiple operations:
```javascript
1
const createProjectWorkflow = {
2
name: 'create_project_workflow',
3
display_name: 'Create Project Workflow',
4
description: 'Create a complete project setup with Jira project, Slack channel, and team notifications',
5
category: 'project_management',
6
provider: 'custom',
7
input_schema: {
8
type: 'object',
9
properties: {
10
project_name: {
11
type: 'string',
12
description: 'Name of the project'
13
},
14
project_key: {
15
type: 'string',
16
description: 'Project key for Jira'
17
},
18
team_members: {
19
type: 'array',
20
items: { type: 'string', format: 'email' },
21
description: 'Team member email addresses'
22
},
23
project_description: {
24
type: 'string',
25
description: 'Project description'
26
}
27
},
28
required: ['project_name', 'project_key', 'team_members']
29
},
30
output_schema: {
31
type: 'object',
32
properties: {
33
jira_project_id: { type: 'string' },
34
slack_channel_id: { type: 'string' },
35
notifications_sent: { type: 'number' }
36
}
37
},
38
implementation: async (parameters, context) => {
39
const { project_name, project_key, team_members, project_description } = parameters;
40
41
try {
42
// Step 1: Create Jira project
43
const jiraProject = await context.tools.execute({
44
tool: 'create_jira_project',
45
parameters: {
46
key: project_key,
47
name: project_name,
48
description: project_description,
49
project_type: 'software'
50
}
51
});
52
53
// Step 2: Create Slack channel
54
const slackChannel = await context.tools.execute({
55
tool: 'create_channel',
56
parameters: {
57
name: `${project_key.toLowerCase()}-team`,
58
topic: `Discussion for ${project_name}`,
59
is_private: false
60
}
61
});
62
63
// Step 3: Send notifications to team members
64
let notificationCount = 0;
65
for (const member of team_members) {
66
try {
67
await context.tools.execute({
68
tool: 'send_email',
69
parameters: {
70
to: [member],
71
subject: `New Project: ${project_name}`,
72
body: `
73
You've been added to the new project "${project_name}".
74
75
Jira Project: ${jiraProject.project_url}
76
Slack Channel: #${slackChannel.channel_name}
77
78
Please join the Slack channel to start collaborating!
79
`
80
}
81
});
82
notificationCount++;
83
} catch (error) {
84
console.error(`Failed to send notification to ${member}:`, error);
85
}
86
}
87
88
// Step 4: Post welcome message to Slack channel
89
await context.tools.execute({
90
tool: 'send_message',
91
parameters: {
92
channel: `#${slackChannel.channel_name}`,
93
text: `<� Welcome to ${project_name}! This channel is for project discussion and updates.`
94
}
95
});
96
97
return {
98
jira_project_id: jiraProject.project_id,
99
slack_channel_id: slackChannel.channel_id,
100
notifications_sent: notificationCount
101
};
102
103
} catch (error) {
104
throw new Error(`Project creation failed: ${error.message}`);
105
}
106
}
107
};
```
### Data processing tool
[Section titled “Data processing tool”](#data-processing-tool)
Create a tool that processes and analyzes data:
```javascript
1
const generateTeamReport = {
2
name: 'generate_team_report',
3
display_name: 'Generate Team Report',
4
description: 'Generate a comprehensive team performance report from multiple sources',
5
category: 'analytics',
6
provider: 'custom',
7
input_schema: {
8
type: 'object',
9
properties: {
10
team_members: {
11
type: 'array',
12
items: { type: 'string', format: 'email' },
13
description: 'Team member email addresses'
14
},
15
start_date: {
16
type: 'string',
17
format: 'date',
18
description: 'Report start date'
19
},
20
end_date: {
21
type: 'string',
22
format: 'date',
23
description: 'Report end date'
24
},
25
include_calendar: {
26
type: 'boolean',
27
default: true,
28
description: 'Include calendar analysis'
29
}
30
},
31
required: ['team_members', 'start_date', 'end_date']
32
},
33
output_schema: {
34
type: 'object',
35
properties: {
36
report_url: { type: 'string' },
37
summary: { type: 'object' },
38
sent_to: { type: 'array', items: { type: 'string' } }
39
}
40
},
41
implementation: async (parameters, context) => {
42
const { team_members, start_date, end_date, include_calendar } = parameters;
43
44
// Fetch Jira issues assigned to team members
45
const jiraIssues = await context.tools.execute({
46
tool: 'fetch_issues',
47
parameters: {
48
jql: `assignee in (${team_members.join(',')}) AND created >= ${start_date} AND created <= ${end_date}`,
49
fields: ['summary', 'status', 'assignee', 'created', 'resolved']
50
}
51
});
52
53
// Fetch calendar events if requested
54
let calendarData = null;
55
if (include_calendar) {
56
calendarData = await context.tools.execute({
57
tool: 'fetch_events',
58
parameters: {
59
start_date: start_date,
60
end_date: end_date,
61
attendees: team_members
62
}
63
});
64
}
65
66
// Process and analyze data
67
const report = {
68
period: { start_date, end_date },
69
team_size: team_members.length,
70
issues: {
71
total: jiraIssues.issues.length,
72
completed: jiraIssues.issues.filter(i => i.status === 'Done').length,
73
in_progress: jiraIssues.issues.filter(i => i.status === 'In Progress').length
74
},
75
meetings: calendarData ? {
76
total: calendarData.events.length,
77
hours: calendarData.events.reduce((acc, event) => acc + event.duration, 0)
78
} : null
79
};
80
81
// Generate HTML report
82
const htmlReport = `
83
84
Team Report - ${start_date} to ${end_date}
85
86
Team Performance Report
87
Summary
88
Team Size: ${report.team_size}
89
Total Issues: ${report.issues.total}
90
Completed Issues: ${report.issues.completed}
91
In Progress: ${report.issues.in_progress}
92
${report.meetings ? `Total Meetings: ${report.meetings.total}
` : ''}
93
94
95
`;
96
97
// Send report via email
98
const emailResults = await Promise.all(
99
team_members.map(member =>
100
context.tools.execute({
101
tool: 'send_email',
102
parameters: {
103
to: [member],
104
subject: `Team Report - ${start_date} to ${end_date}`,
105
html_body: htmlReport
106
}
107
})
108
)
109
);
110
111
return {
112
report_url: 'Generated and sent via email',
113
summary: report,
114
sent_to: team_members.filter((_, index) => emailResults[index].status === 'sent')
115
};
116
}
117
};
```
## Registering custom tools
[Section titled “Registering custom tools”](#registering-custom-tools)
### Using the API
[Section titled “Using the API”](#using-the-api)
Register your custom tools with Agent Auth:
* JavaScript
```javascript
1
// Register a custom tool
2
const registeredTool = await agentConnect.tools.register({
3
...sendWelcomeEmail,
4
organization_id: 'your_org_id'
5
});
6
7
console.log('Tool registered:', registeredTool.id);
```
* Python
```python
1
# Register a custom tool
2
registered_tool = agent_connect.tools.register(
3
**send_welcome_email,
4
organization_id='your_org_id'
5
)
6
7
print(f'Tool registered: {registered_tool.id}')
```
* cURL
```bash
1
curl -X POST "${SCALEKIT_BASE_URL}/v1/connect/tools/custom" \
2
-H "Authorization: Bearer ${SCALEKIT_CLIENT_SECRET}" \
3
-H "Content-Type: application/json" \
4
-d '{
5
"name": "send_welcome_email",
6
"display_name": "Send Welcome Email",
7
"description": "Send a personalized welcome email to new users",
8
"category": "communication",
9
"provider": "custom",
10
"input_schema": {...},
11
"output_schema": {...},
12
"implementation": "async (parameters, context) => {...}"
13
}'
```
### Using the dashboard
[Section titled “Using the dashboard”](#using-the-dashboard)
1. In the [Scalekit dashboard](https://app.scalekit.com), go to **AgentKit** > **Tools**
2. Click **Create Custom Tool**
3. Fill in the tool definition form
4. Test the tool with sample parameters
5. Save and activate the tool
## Tool context and utilities
[Section titled “Tool context and utilities”](#tool-context-and-utilities)
The `context` object provides access to:
### Standard tools
[Section titled “Standard tools”](#standard-tools)
Execute any standard Agent Auth tool:
```javascript
1
// Execute standard tools
2
const result = await context.tools.execute({
3
tool: 'send_email',
4
parameters: { ... }
5
});
6
7
// Execute with specific connected account
8
const result = await context.tools.execute({
9
connected_account_id: 'specific_account',
10
tool: 'send_email',
11
parameters: { ... }
12
});
```
### Connected accounts
[Section titled “Connected accounts”](#connected-accounts)
Access connected account information:
```javascript
1
// Get connected account details
2
const account = await context.accounts.get(accountId);
3
4
// List accounts for a user
5
const accounts = await context.accounts.list({
6
identifier: 'user_123',
7
provider: 'gmail'
8
});
```
### Utilities
[Section titled “Utilities”](#utilities)
Access utility functions:
```javascript
1
// Generate unique IDs
2
const id = context.utils.generateId();
3
4
// Format dates
5
const formatted = context.utils.formatDate(date, 'YYYY-MM-DD');
6
7
// Validate email
8
const isValid = context.utils.isValidEmail(email);
9
10
// HTTP requests
11
const response = await context.utils.httpRequest({
12
url: 'https://api.example.com/data',
13
method: 'GET',
14
headers: { 'Authorization': 'Bearer token' }
15
});
```
### Error handling
[Section titled “Error handling”](#error-handling)
Throw structured errors:
```javascript
1
// Throw validation error
2
throw new context.errors.ValidationError('Invalid email format');
3
4
// Throw business logic error
5
throw new context.errors.BusinessLogicError('User not found');
6
7
// Throw external API error
8
throw new context.errors.ExternalAPIError('GitHub API returned 500');
```
## Testing custom tools
[Section titled “Testing custom tools”](#testing-custom-tools)
### Unit testing
[Section titled “Unit testing”](#unit-testing)
Test custom tools in isolation:
```javascript
1
// Mock context for testing
2
const mockContext = {
3
tools: {
4
execute: jest.fn().mockResolvedValue({
5
message_id: 'test_msg_123',
6
status: 'sent'
7
})
8
},
9
utils: {
10
generateId: () => 'test_id_123',
11
formatDate: (date, format) => '2024-01-15'
12
}
13
};
14
15
// Test custom tool
16
const result = await sendWelcomeEmail.implementation({
17
user_name: 'John Doe',
18
user_email: 'john@example.com',
19
company_name: 'Acme Corp'
20
}, mockContext);
21
22
expect(result.status).toBe('sent');
23
expect(mockContext.tools.execute).toHaveBeenCalledWith({
24
tool: 'send_email',
25
parameters: expect.objectContaining({
26
to: ['john@example.com'],
27
subject: 'Welcome to Acme Corp!'
28
})
29
});
```
### Integration testing
[Section titled “Integration testing”](#integration-testing)
Test with real Agent Auth:
```javascript
1
// Test custom tool with real connections
2
const testResult = await agentConnect.tools.execute({
3
connected_account_id: 'test_gmail_account',
4
tool: 'send_welcome_email',
5
parameters: {
6
user_name: 'Test User',
7
user_email: 'test@example.com',
8
company_name: 'Test Company'
9
}
10
});
11
12
console.log('Test result:', testResult);
```
## Best practices
[Section titled “Best practices”](#best-practices)
### Tool design
[Section titled “Tool design”](#tool-design)
* **Single responsibility**: Each tool should have a clear, single purpose
* **Consistent naming**: Use descriptive, consistent naming conventions
* **Clear documentation**: Provide detailed descriptions and examples
* **Error handling**: Implement comprehensive error handling
* **Input validation**: Validate all input parameters
### Performance optimization
[Section titled “Performance optimization”](#performance-optimization)
* **Parallel execution**: Use Promise.all() for independent operations
* **Caching**: Cache frequently accessed data
* **Batch operations**: Group similar operations together
* **Timeout handling**: Set appropriate timeouts for external calls
### Security considerations
[Section titled “Security considerations”](#security-considerations)
* **Input sanitization**: Sanitize all user inputs
* **Permission checks**: Verify user permissions before execution
* **Sensitive data**: Handle sensitive data securely
* **Rate limiting**: Implement rate limiting for resource-intensive operations
## Custom tool examples
[Section titled “Custom tool examples”](#custom-tool-examples)
### Slack notification tool
[Section titled “Slack notification tool”](#slack-notification-tool)
```javascript
1
const sendSlackNotification = {
2
name: 'send_slack_notification',
3
display_name: 'Send Slack Notification',
4
description: 'Send formatted notifications to Slack with optional mentions',
5
category: 'communication',
6
provider: 'custom',
7
input_schema: {
8
type: 'object',
9
properties: {
10
channel: { type: 'string' },
11
message: { type: 'string' },
12
severity: { type: 'string', enum: ['info', 'warning', 'error'] },
13
mentions: { type: 'array', items: { type: 'string' } }
14
},
15
required: ['channel', 'message']
16
},
17
output_schema: {
18
type: 'object',
19
properties: {
20
message_ts: { type: 'string' },
21
permalink: { type: 'string' }
22
}
23
},
24
implementation: async (parameters, context) => {
25
const { channel, message, severity = 'info', mentions = [] } = parameters;
26
27
const colors = {
28
info: 'good',
29
warning: 'warning',
30
error: 'danger'
31
};
32
33
const mentionText = mentions.length > 0 ?
34
`${mentions.map(m => `<@${m}>`).join(' ')} ` : '';
35
36
return await context.tools.execute({
37
tool: 'send_message',
38
parameters: {
39
channel,
40
text: `${mentionText}${message}`,
41
attachments: [
42
{
43
color: colors[severity],
44
text: message,
45
ts: Math.floor(Date.now() / 1000)
46
}
47
]
48
}
49
});
50
}
51
};
```
### Calendar scheduling tool
[Section titled “Calendar scheduling tool”](#calendar-scheduling-tool)
```javascript
1
const scheduleTeamMeeting = {
2
name: 'schedule_team_meeting',
3
display_name: 'Schedule Team Meeting',
4
description: 'Find available time slots and schedule team meetings',
5
category: 'scheduling',
6
provider: 'custom',
7
input_schema: {
8
type: 'object',
9
properties: {
10
attendees: { type: 'array', items: { type: 'string' } },
11
duration: { type: 'number', minimum: 15 },
12
preferred_times: { type: 'array', items: { type: 'string' } },
13
meeting_title: { type: 'string' },
14
meeting_description: { type: 'string' }
15
},
16
required: ['attendees', 'duration', 'meeting_title']
17
},
18
output_schema: {
19
type: 'object',
20
properties: {
21
event_id: { type: 'string' },
22
scheduled_time: { type: 'string' },
23
attendees_notified: { type: 'number' }
24
}
25
},
26
implementation: async (parameters, context) => {
27
const { attendees, duration, preferred_times, meeting_title, meeting_description } = parameters;
28
29
// Find available time slots
30
const availableSlots = await context.tools.execute({
31
tool: 'find_available_slots',
32
parameters: {
33
attendees,
34
duration,
35
preferred_times: preferred_times || []
36
}
37
});
38
39
if (availableSlots.length === 0) {
40
throw new context.errors.BusinessLogicError('No available time slots found');
41
}
42
43
// Schedule the meeting at the first available slot
44
const selectedSlot = availableSlots[0];
45
const event = await context.tools.execute({
46
tool: 'create_event',
47
parameters: {
48
title: meeting_title,
49
description: meeting_description,
50
start_time: selectedSlot.start_time,
51
end_time: selectedSlot.end_time,
52
attendees
53
}
54
});
55
56
return {
57
event_id: event.event_id,
58
scheduled_time: selectedSlot.start_time,
59
attendees_notified: attendees.length
60
};
61
}
62
};
```
## Versioning and deployment
[Section titled “Versioning and deployment”](#versioning-and-deployment)
### Version management
[Section titled “Version management”](#version-management)
Version your custom tools for backward compatibility:
```javascript
1
const toolV2 = {
2
...originalTool,
3
version: '2.0.0',
4
// Updated implementation
5
};
6
7
// Deploy new version
8
await agentConnect.tools.register(toolV2);
9
10
// Deprecate old version
11
await agentConnect.tools.deprecate(originalTool.name, '1.0.0');
```
### Deployment strategies
[Section titled “Deployment strategies”](#deployment-strategies)
* **Blue-green deployment**: Deploy new version alongside old version
* **Canary deployment**: Gradually roll out to subset of users
* **Feature flags**: Use feature flags to control tool availability
* **Rollback strategy**: Plan for quick rollback if issues arise
Note
**Ready to build?** Start with simple custom tools and gradually add complexity. Test thoroughly before deploying to production, and consider the impact on your users when making changes.
Custom tools unlock the full potential of Agent Auth by allowing you to create specialized workflows that perfectly match your business needs. With proper design, testing, and deployment practices, you can build powerful tools that enhance your team’s productivity and streamline complex operations.
---
# DOCUMENT BOUNDARY
---
# Scalekit optimized built-in tools
> Call Scalekit's pre-built tools across 200+ connectors. Each tool returns structured, LLM-ready output with no endpoint URLs, auth headers, or parsing needed.
Scalekit ships pre-built tools for every connector in the catalog: GitHub, Gmail, Slack, Salesforce, Notion, Linear, HubSpot, and more. Each tool has an LLM-ready schema and returns structured output. Your agent passes inputs; Scalekit injects the user’s credentials and handles the API call.
This page assumes you have an `ACTIVE` connected account for the user. If not, see [Authorize a user](/agentkit/tools/authorize/).
## Get available tools for a user
[Section titled “Get available tools for a user”](#get-available-tools-for-a-user)
Use `list_scoped_tools` / `listScopedTools` to get the tools this specific user is authorized to call. **This is the list you pass to your LLM.**
* Python
```python
1
from google.protobuf.json_format import MessageToDict
2
3
scoped_response, _ = actions.tools.list_scoped_tools(
4
identifier="user_123",
5
filter={"connection_names": ["github-connect"]}, # optional; omit for all connectors
6
page_size=100, # fetch beyond the default page
7
)
8
for scoped_tool in scoped_response.tools:
9
definition = MessageToDict(scoped_tool.tool).get("definition", {})
10
print(definition.get("name"))
11
print(definition.get("input_schema")) # JSON Schema; pass directly to your LLM
```
* Node.js
```typescript
1
const { tools } = await scalekit.tools.listScopedTools('user_123', {
2
filter: { connectionNames: ['github-connect'] }, // use filter: {} to list every connector
3
pageSize: 100, // fetch beyond the default page
4
});
5
for (const tool of tools) {
6
const { name, input_schema } = tool.tool.definition;
7
console.log(name, input_schema); // JSON Schema; pass directly to your LLM
8
}
```
To explore tools interactively, use the playground at [**Scalekit Dashboard**](https://app.scalekit.com) **> AgentKit > Playground**.
## Execute a tool
[Section titled “Execute a tool”](#execute-a-tool)
Use `execute_tool` / `executeTool` to run a named tool for a specific user. Scalekit identifies the connected account with:
* User identifier (`identifier`) + Connection name as shown in the Scalekit Dashboard (`connection_name`), or
* Connected Account ID (`connected_account_id`) — autogenerated by Scalekit and visible in the Scalekit Dashboard
- Python
```python
1
# connected account is selected using the user identifier and the connection name
2
result = actions.execute_tool(
3
tool_name="github_user_repos_list",
4
identifier="user_123",
5
connection_name="github-connect",
6
tool_input={"per_page": 5, "sort": "updated"},
7
)
8
print(result.data)
9
10
# alternatively, use the connected account ID
11
# result = actions.execute_tool(
12
# tool_name="github_user_repos_list",
13
# connected_account_id="ca_xxxxxx",
14
# tool_input={"per_page": 5, "sort": "updated"},
15
# )
```
- Node.js
```typescript
1
// connected account is selected using the user identifier and the connector
2
const result = await scalekit.actions.executeTool({
3
toolName: 'github_user_repos_list',
4
identifier: 'user_123',
5
connector: 'github-connect',
6
toolInput: { per_page: 5, sort: 'updated' },
7
});
8
console.log(result.data);
9
10
// alternatively, use the connected account ID
11
// const result = await scalekit.actions.executeTool({
12
// toolName: 'github_user_repos_list',
13
// connectedAccountId: 'ca_xxxxxx',
14
// toolInput: { per_page: 5, sort: 'updated' },
15
// });
```
## Understand tool response shape
[Section titled “Understand tool response shape”](#understand-tool-response-shape)
`execute_tool` / `executeTool` returns a **wrapper object**, not the provider payload directly. Tool output lives under `response.data` (Python) or `result.data` (Node.js). Keys inside `data` depend on the tool you called.
When you integrate a new tool, log the full wrapper once, then read fields from `data`:
* Python
List Google Calendar events (Python)
```python
1
response = actions.execute_tool(
2
tool_name="googlecalendar_list_events",
3
identifier="user_123",
4
connection_name="googlecalendar",
5
tool_input={"max_results": 10},
6
)
7
8
# Security: Log only in development; production logs may expose user data.
9
print(response.data)
10
11
events = response.data.get("events", [])
12
next_page_token = response.data.get("next_page_token")
13
print(f"Found {len(events)} events")
```
* Node.js
List Google Calendar events (Node.js)
```typescript
1
const result = await scalekit.actions.executeTool({
2
toolName: 'googlecalendar_list_events',
3
identifier: 'user_123',
4
connector: 'googlecalendar',
5
toolInput: { max_results: 10 },
6
});
7
8
// Security: Log only in development; production logs may expose user data.
9
console.log(result.data);
10
11
const events = (result.data as { events?: unknown[] }).events ?? [];
12
const nextPageToken = (result.data as { next_page_token?: string }).next_page_token;
13
console.log(`Found ${events.length} events`);
```
Do not treat the wrapper as the tool payload
A common integration mistake is parsing `response` as if it were a flat list of events. Always read `response.data` first, then extract tool-specific keys such as `events` and `next_page_token`.
## Wire into your LLM
[Section titled “Wire into your LLM”](#wire-into-your-llm)
The full agent loop: fetch scoped tools → pass to LLM → execute tool calls → feed results back.
* Python
```python
1
import anthropic
2
from google.protobuf.json_format import MessageToDict
3
4
client = anthropic.Anthropic()
5
6
# 1. Fetch tools scoped to this user
7
scoped_response, _ = actions.tools.list_scoped_tools(
8
identifier="user_123",
9
filter={"connection_names": ["github-connect"]},
10
page_size=100, # fetch beyond the default page so no connector tools are missed
11
)
12
llm_tools = [
13
{
14
"name": MessageToDict(t.tool).get("definition", {}).get("name"),
15
"description": MessageToDict(t.tool).get("definition", {}).get("description"),
16
"input_schema": MessageToDict(t.tool).get("definition", {}).get("input_schema", {}),
17
}
18
for t in scoped_response.tools
19
]
20
21
# 2. Send to LLM
22
messages = [{"role": "user", "content": "Summarize my 5 most recently updated repositories"}]
23
response = client.messages.create(
24
model="claude-sonnet-4-6",
25
max_tokens=1024,
26
tools=llm_tools,
27
messages=messages,
28
)
29
30
# 3. Execute tool calls and feed results back
31
for block in response.content:
32
if block.type == "tool_use":
33
tool_result = actions.execute_tool(
34
tool_name=block.name,
35
identifier="user_123",
36
tool_input=block.input,
37
)
38
messages.append({"role": "assistant", "content": response.content})
39
messages.append({
40
"role": "user",
41
"content": [{"type": "tool_result", "tool_use_id": block.id, "content": str(tool_result.data)}],
42
})
```
* Node.js
```typescript
1
import Anthropic from '@anthropic-ai/sdk';
2
3
const anthropic = new Anthropic();
4
5
// 1. Fetch tools scoped to this user
6
const { tools } = await scalekit.tools.listScopedTools('user_123', {
7
filter: { connectionNames: ['github-connect'] },
8
pageSize: 100, // fetch beyond the default page so no connector tools are missed
9
});
10
const llmTools = tools.map((t) => ({
11
name: t.tool.definition.name,
12
description: t.tool.definition.description,
13
input_schema: t.tool.definition.input_schema,
14
}));
15
16
// 2. Send to LLM
17
const messages: Anthropic.MessageParam[] = [
18
{ role: 'user', content: 'Summarize my 5 most recently updated repositories' },
19
];
20
const response = await anthropic.messages.create({
21
model: 'claude-sonnet-4-6',
22
max_tokens: 1024,
23
tools: llmTools,
24
messages,
25
});
26
27
// 3. Execute tool calls and feed results back
28
for (const block of response.content) {
29
if (block.type === 'tool_use') {
30
const toolResult = await scalekit.actions.executeTool({
31
toolName: block.name,
32
identifier: 'user_123',
33
toolInput: block.input as Record,
34
});
35
messages.push({ role: 'assistant', content: response.content });
36
messages.push({
37
role: 'user',
38
content: [{ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(toolResult.data) }],
39
});
40
}
41
}
```
## Use a framework adapter
[Section titled “Use a framework adapter”](#use-a-framework-adapter)
For LangChain and Google ADK, Scalekit returns native tool objects in Python with no schema reshaping needed.
* LangChain
```python
1
from langchain_openai import ChatOpenAI
2
from langchain.agents import create_agent
3
4
tools = actions.langchain.get_tools(
5
identifier="user_123",
6
connection_names=["github-connect"],
7
page_size=100, # avoid missing tools when a connector has more than the default page
8
)
9
llm = ChatOpenAI(model="claude-sonnet-4-6")
10
agent = create_agent(model=llm, tools=tools, system_prompt="You are a helpful assistant.")
11
result = agent.invoke({"messages": [{"role": "user", "content": "List my 5 most recently updated repositories"}]})
```
* Google ADK
```python
1
from google.adk.agents import Agent
2
from google.adk.models.lite_llm import LiteLlm
3
4
github_tools = actions.google.get_tools(
5
identifier="user_123",
6
connection_names=["github-connect"],
7
page_size=100, # avoid missing tools when a connector has more than the default page
8
)
9
agent = Agent(
10
name="github_assistant",
11
model=LiteLlm(model="claude-sonnet-4-6"),
12
tools=github_tools,
13
)
```
* Node.js (Vercel AI SDK)
```typescript
1
import { generateText, jsonSchema, tool } from 'ai';
2
3
const { tools: scopedTools } = await scalekit.tools.listScopedTools('user_123', {
4
filter: { connectionNames: ['github-connect'] },
5
pageSize: 100, // fetch beyond the default page so no connector tools are missed
6
});
7
const tools = Object.fromEntries(
8
scopedTools.map((t) => [
9
t.tool.definition.name,
10
tool({
11
description: t.tool.definition.description,
12
parameters: jsonSchema(t.tool.definition.input_schema ?? { type: 'object', properties: {} }),
13
execute: async (args) => {
14
const result = await scalekit.actions.executeTool({
15
toolName: t.tool.definition.name,
16
toolInput: args,
17
identifier: 'user_123',
18
});
19
return result.data;
20
},
21
}),
22
]),
23
);
```
MCP-compatible frameworks
Prefer a single interface any MCP client can consume? See [Virtual MCP Servers](/agentkit/mcp/overview/).
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
Connected account stays in `PENDING`
The user hasn’t completed the OAuth flow yet. Call `get_authorization_link` and redirect the user to the link. Retry after consent completes.
Tool call fails with resource not found
Check three things:
* The connector name exists in **AgentKit** > **Connections**
* The `identifier` matches the one used when creating the connected account
* Call `list_scoped_tools` and only execute tool names it returns
Connection names differ across environments
Connection names are workspace-specific. Don’t hard-code them. Use environment variables (`GITHUB_CONNECTION_NAME`, `GMAIL_CONNECTION_NAME`) and reference those in API calls.
If you need an endpoint not covered by optimized tools, see [Custom tools](/agentkit/tools/custom-tools/).
---
# DOCUMENT BOUNDARY
---
# Verify user identity
> Confirm that the user who completed the OAuth consent is the same user your app intended to connect.
User verification applies to OAuth-based connectors only. For API key, basic auth, and key pair connectors, the user provides credentials directly. No OAuth flow, no verification step needed.
For OAuth connectors, before activating a connected account, Scalekit confirms that the user who completed the OAuth consent is the same user your app intended to connect. This **user verification** step runs every time a connected account is authorized and prevents OAuth consent from activating on the wrong account.
Choose a mode in **AgentKit** > **User Verification**:
* **Custom user verification**: Your server confirms the authorizing user matches the user your app intended to connect. Use in production. Without this, any user who receives an authorization link can activate a connected account (including the wrong one).
* **Scalekit users only**: Scalekit checks that the authorizing user is signed in to your Scalekit dashboard. No code required. Use during development and internal testing when all users are already on your team.
Scalekit users only is for testing
In this mode, the user authorizing the connection must already be signed in to the Scalekit dashboard. No verify route or API calls are needed in your code. Switch to **Custom user verification** before onboarding real users.

Your application implements the verify step. End users never interact with Scalekit directly.
When the user finishes OAuth, Scalekit redirects to your verify URL with `auth_request_id` and `state` params. Your route reads the user from your session, calls Scalekit’s verify API with the `auth_request_id` and the original `identifier`, and if they match, the connected account activates.
Review the verification sequence
## Implement verification in your app
[Section titled “Implement verification in your app”](#implement-verification-in-your-app)
If you haven’t installed the SDK yet, see the [quickstart](/agentkit/quickstart/).
### Generate the authorization link
[Section titled “Generate the authorization link”](#generate-the-authorization-link)
Pass these fields when creating the authorization link:
| Field | Description |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `identifier` | **Required.** Your user’s ID or email. Scalekit stores this and checks it matches at verify time. |
| `user_verify_url` | **Required.** Your callback URL; Scalekit redirects the user here after OAuth completes. |
| `state` | **Recommended.** A random value to prevent CSRF. |
How to use state
Generate a cryptographically random value per flow, store it in a secure HTTP-only cookie, and validate it against the `state` query param on callback. Discard the request if they don’t match; this prevents an attacker from sending crafted verify URLs to your users.
* Python
```python
1
import secrets
2
3
# Generate a state value to prevent CSRF
4
state = secrets.token_urlsafe(32)
5
# Store state in a secure, HTTP-only cookie to validate on callback
6
7
response = scalekit_client.actions.get_authorization_link(
8
connection_name=connector,
9
identifier=user_id,
10
user_verify_url="https://app.yourapp.com/user/verify",
11
state=state,
12
)
```
* Node.js
```typescript
1
import crypto from 'node:crypto';
2
3
// Generate a state value to prevent CSRF
4
const state = crypto.randomUUID();
5
// Store state in a secure, HTTP-only cookie to validate on callback
6
7
const { link } = await scalekit.actions.getAuthorizationLink({
8
identifier: userId,
9
connectionName: connector,
10
userVerifyUrl: 'https://app.yourapp.com/user/verify',
11
state,
12
});
```
### Handle the verification callback
[Section titled “Handle the verification callback”](#handle-the-verification-callback)
After OAuth completes, Scalekit redirects to your `user_verify_url`:
```http
1
GET https://app.yourapp.com/user/verify?auth_request_id=req_xyz&state=
```
Validate `state` against your cookie, then call Scalekit’s verify endpoint server-side.
Never trust query params for identity
Read the user’s identity from your own session, not from the URL. Use `state` for session correlation only.
* Python
```python
1
# 1. Validate state from query param matches state in cookie
2
# 2. Read user identity from your session, not from the URL
3
4
response = scalekit_client.actions.verify_connected_account_user(
5
auth_request_id=auth_request_id,
6
identifier=user_id, # must match what was stored at link creation
7
)
8
# On success: redirect to response.post_user_verify_redirect_url
```
* Node.js
```typescript
1
// 1. Validate state from query param matches state in cookie
2
// 2. Read user identity from your session, not from the URL
3
4
const { postUserVerifyRedirectUrl } =
5
await scalekit.actions.verifyConnectedAccountUser({
6
authRequestId: auth_request_id,
7
identifier: userId, // must match what was stored at link creation
8
});
9
// On success: redirect to postUserVerifyRedirectUrl
```
On success, the connected account is activated. Redirect the user using `post_user_verify_redirect_url`.
## Common scenarios
[Section titled “Common scenarios”](#common-scenarios)
Why does authorization fail when no verification redirect URL is configured?
The authorization flow fails with a `failed_to_exchange` error (`user_verify_url not configured for verification redirect`) when the connection is set to **Custom user verification**, but the flow started without a `user_verify_url`. Scalekit completes the OAuth exchange but has nowhere to redirect the user for verification.
Resolve it in one of two ways:
* **In production**, pass `user_verify_url` when you generate the authorization link, and implement the [verification callback](#handle-the-verification-callback) at that URL.
* **In development or internal testing**, set the mode to **Scalekit users only** in **AgentKit** > **User Verification**. This mode needs no `user_verify_url` and no verify route, as long as every authorizing user is signed in to your Scalekit dashboard.
---
# DOCUMENT BOUNDARY
---
# User authentication flow
> Learn how Scalekit routes users through authentication based on login method and organization SSO policies.
The user’s authentication journey on the hosted login page can differ based on the **login method** they choose and the **organization policies** configured in Scalekit.
## Organization policies
[Section titled “Organization policies”](#organization-policies)
Organizations can enforce Enterprise SSO for their users. An organization must create an enabled [SSO connection](/authenticate/auth-methods/enterprise-sso/) and add [organization domains](/authenticate/manage-users-orgs/organization-domains/).
Scalekit uses **Home Realm Discovery (HRD)** to determine whether a user’s email domain matches a configured organization domain. When a match is found, the user is routed to that organization’s SSO identity provider.
**Examples**
* A user tries to log in as `user@samecorp.com` on the hosted login page. If `samecorp.com` is registered as an organization domain with SSO enabled, the user is redirected to that organization’s IdP to complete authentication.
* A user tries to log in with Google as `user@samecorp.com` on the hosted login page. If `samecorp.com` is registered as an organization domain with SSO enabled, the user is redirected to that organization’s IdP after returning from Google.
## Login method–specific behavior
[Section titled “Login method–specific behavior”](#login-methodspecific-behavior)
Scalekit allows users to choose different login methods on the hosted login page. The timing of organization domain checks differs slightly by method, but the rules remain consistent.
### Social login
[Section titled “Social login”](#social-login)
* User authenticates with a social IdP (e.g., Google, GitHub).
* Scalekit evaluates the user’s email after social auth completes.
* Home Realm Discovery (HRD) checks whether the email domain matches an organization domain.
* **Domain match:** User is redirected to the organization’s SSO IdP.
* **No match:** Authentication completes.
This ensures that enterprise users must complete SSO authentication even if they initially choose social login.
### Passkey login
[Section titled “Passkey login”](#passkey-login)
* User authenticates using a passkey.
* Authentication succeeds immediately.
* Scalekit performs Home Realm Discovery (HRD) to check the email domain.
* **Domain match:** User is redirected to SSO.
* **No match:** Authentication completes.
Passkeys authenticate the user, but do not override organization SSO policy.
### Email-based login
[Section titled “Email-based login”](#email-based-login)
* User enters their email address.
* Home Realm Discovery (HRD) runs **before authentication** to check the email domain.
* **Domain match:** User is redirected to SSO.
* **No match:** Scalekit performs OTP or magic link verification, then authentication completes.
### Authentication flow
[Section titled “Authentication flow”](#authentication-flow)
This diagram shows the different variations of user’s authentication journey on the hosted login page.
***
## Enterprise SSO Trust model
[Section titled “Enterprise SSO Trust model”](#enterprise-sso-trust-model)
Most enterprise identity providers (IdPs) like Okta or Microsoft Entra do not prove that a user actually controls the email inbox they sign in with. They only assert an email address in the SAML/OIDC token. Because of this, when a user logs in via Enterprise SSO, Scalekit does not automatically treat that SSO connection as a trusted source of email ownership.
Since Scalekit cannot be sure that the SSO user truly owns the email address, the user is taken through an email ownership check (magic link or OTP) to prove control of that inbox. After the user successfully verifies their email, that SSO connection is marked as a verified channel for that specific user, and they do not need to verify email ownership again on subsequent logins via the same connection.
If you want an Enterprise SSO connection to be treated as a trusted provider for a specific domain, you can assign one or more domains to the organization. Then, for users logging in via that Enterprise SSO connection whose email address matches one of the configured domains, Scalekit skips additional email ownership verification.
| SSO trust case | Example | Result |
| -------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Trusted SSO | Org has added `acmecorp.com` in organization domain. User authenticates as `user@acmecorp.com` with organization SSO. | Email ownership trusted |
| Untrusted SSO | Org has added `acmecorp.com` in organization domain and user authenticates as `user@foocorp.com` with organization SSO. | Email ownership not trusted → Additional verification required |
***
## Forcing SSO from your application
[Section titled “Forcing SSO from your application”](#forcing-sso-from-your-application)
Your app can override Home Realm Discovery (HRD) by passing `organization_id` or `connection_id` in the authentication request ↗ to Scalekit. When you do this:
* Scalekit skips HRD and redirects the user directly to the specified SSO IdP.
* After SSO authentication completes, Scalekit checks whether the user’s email domain matches one of the organization domains configured on that SSO connection.
* **Domain match**: authentication completes.
* **No match**: Scalekit requires additional verification (OTP or magic link) before completing authentication.
## IdP‑initiated SSO
[Section titled “IdP‑initiated SSO”](#idpinitiated-sso)
In IdP‑initiated SSO, authentication starts at the identity provider instead of your application or the hosted login page. After the IdP authenticates the user and redirects to Scalekit, Scalekit evaluates email ownership trust:
* If the user’s email domain matches one of the organization domains configured on the SSO connection, authentication completes.
* If the email domain does not match, Scalekit requires additional verification (OTP or magic link) before completing authentication.
This workflow ensures IdP‑initiated flows follow the same email ownership and trust guarantees as app‑initiated SSO
***
## Account linking
[Section titled “Account linking”](#account-linking)
### What happens
[Section titled “What happens”](#what-happens)
Scalekit maintains a single user record per email address. For example, if a user first authenticates with passwordless login (magic link/OTP) and later uses Google or Enterprise SSO, Scalekit links both identities to the same user record. These identities are stored on the user object for your app to read if needed. This avoids duplicate users when people switch authentication methods.
### Why it is safe
[Section titled “Why it is safe”](#why-it-is-safe)
Scalekit only treats an SSO IdP as a trusted source of email ownership when:
* the authenticated email domain matches one of the organization domains configured on the SSO connection, or
* the user has previously proven email ownership via magic link or OTP.
Because the organization has proven domain ownership, and/or the user has proven inbox control, emails from that SSO connection are treated as valid. This prevents attackers from linking identities unless email ownership has been verified through trusted mechanisms.
---
# DOCUMENT BOUNDARY
---
# Implement enterprise SSO
> How to implement enterprise SSO for your application
Enterprise single sign-on (SSO) enables users to authenticate using their organization’s identity provider (IdP), such as Okta, Azure AD, or Google Workspace. [After completing the quickstart](/authenticate/fsa/quickstart/), follow this guide to implement SSO for an organization, streamline admin onboarding, enforce login requirements, and validate your configuration.
1. ## Enable SSO for the organization
[Section titled “Enable SSO for the organization”](#enable-sso-for-the-organization)
When a user signs up for your application, Scalekit automatically creates an organization and assigns an admin role to the user. Provide an option in your user interface to enable SSO for the organization or workspace.
Here’s how you can do that with Scalekit. Use the following SDK method to activate SSO for the organization:
* Node.js
Enable SSO
```javascript
const settings = {
features: [
{
name: 'sso',
enabled: true,
}
],
};
await scalekit.organization.updateOrganizationSettings(
'', // Get this from the idToken or accessToken
settings
);
```
* Python
Enable SSO
```python
settings = [
{
"name": "sso",
"enabled": True
}
]
scalekit.organization.update_organization_settings(
organization_id='', # Get this from the idToken or accessToken
settings=settings
)
```
* Java
Enable SSO
```java
OrganizationSettingsFeature featureSSO = OrganizationSettingsFeature.newBuilder()
.setName("sso")
.setEnabled(true)
.build();
updatedOrganization = scalekitClient.organizations()
.updateOrganizationSettings(organizationId, List.of(featureSSO));
```
* Go
Enable SSO
```go
settings := OrganizationSettings{
Features: []Feature{
{
Name: "sso",
Enabled: true,
},
},
}
organization, err := sc.Organization().UpdateOrganizationSettings(ctx, organizationId, settings)
if err != nil {
// Handle error
}
```
You can also enable this from the [organization settings](/authenticate/fsa/user-management-settings/) in the Scalekit dashboard.
2. ## Enable admin portal for enterprise customer onboarding
[Section titled “Enable admin portal for enterprise customer onboarding”](#enable-admin-portal-for-enterprise-customer-onboarding)
After SSO is enabled for that organization, provide a method for configuring a SSO connection with the organization’s identity provider.
Scalekit offers two primary approaches:
* Generate a link to the admin portal from the Scalekit dashboard and share it with organization admins via your usual channels.
* Or embed the admin portal in your application in an inline frame so administrators can configure their IdP without leaving your app.
[See how to onboard enterprise customers](/sso/guides/onboard-enterprise-customers/)
3. ## Identify and enforce SSO for organization users
[Section titled “Identify and enforce SSO for organization users”](#identify-and-enforce-sso-for-organization-users)
Administrators typically register [organization-owned domains](/authenticate/manage-users-orgs/organization-domains/) through the admin portal. When a user attempts to sign in with an email address matching a registered domain, they are automatically redirected to their organization’s designated identity provider for authentication. This is also known as **Home Realm Discovery**.
**Organization domains** automatically route users to the correct SSO connection based on their email address. When a user signs in with an email domain that matches a registered organization domain, Scalekit redirects them to that organization’s SSO provider and enforces SSO login.
For example, if an organization registers `megacorp.org`, any user signing in with an `joe@megacorp.org` email address is redirected to Megacorp’s SSO provider.

Navigate to **Dashboard > Organizations** and select the target organization > **Overview** > **Organization Domains** section to register organization domains.
4. ## Test your SSO integration
[Section titled “Test your SSO integration”](#test-your-sso-integration)
Scalekit offers a “Test Organization” feature that enables SSO flow validation without requiring test accounts from your customers’ identity providers.
To quickly test the integration, enter an email address using the domains `joe@example.com` or `jane@example.org`. This will trigger a redirect to the IdP simulator, which serves as the test organization’s identity provider for authentication.
For a comprehensive step-by-step walkthrough, refer to the [Test SSO integration guide](/sso/guides/test-sso/).
---
# DOCUMENT BOUNDARY
---
# Add passkeys login method
> Enable passkey authentication for your users
Passkeys replace passwords with biometric authentication (fingerprint, face recognition) or device PINs. Built on FIDO® standards (WebAuthn and CTAP), passkeys offer superior security by eliminating phishing and credential stuffing vulnerabilities, while also providing a seamless one-tap login experience. Unlike traditional authentication methods, passkeys sync across devices, removing the need for multiple enrollments and providing better recovery options when devices are lost.
Your [existing Scalekit integration](/authenticate/fsa/quickstart) already supports passkeys. To implement, enable passkeys in the Scalekit dashboard and leverage Scalekit’s built-in user passkey registration functionality.
1. ## Enable passkeys in the Scalekit dashboard
[Section titled “Enable passkeys in the Scalekit dashboard”](#enable-passkeys-in-the-scalekit-dashboard)
Go to Scalekit Dashboard > Authentication > Auth methods > Passkeys and click “Enable”

2. ## Manage passkey registration
[Section titled “Manage passkey registration”](#manage-passkey-registration)
Let users manage passkeys just by redirecting them to Scalekit from your app (usually through a button in your app that says “Manage passkeys”), or building your own UI.
#### Using Scalekit UI
[Section titled “Using Scalekit UI”](#using-scalekit-ui)
To enable users to register and manage their passkeys, redirect them to the Scalekit passkey registration page.

Construct the URL by appending `/ui/profile/passkeys` to your Scalekit environment URL
Passkey Registration URL
```js
/ui/profile/passkeys
```
This opens a page where users can:
* Register new passkeys
* Remove existing passkeys
* View their registered passkeys
Note
Scalekit registers & authenticates user’s passkeys through the browser’s native passkey API. This API prompts users to authenticate with device-supported passkeys — such as fingerprint, PIN, or password managers.
To show the page on your own domain instead of the default Scalekit domain, configure a [branded custom domain](/guides/custom-domain). Users then see a URL such as `https://auth.yourapp.com/ui/profile/passkeys`.
#### In your own UI
[Section titled “In your own UI”](#in-your-own-ui)
If you prefer to create a custom user interface for passkey management, Scalekit offers comprehensive APIs that enable you to build a personalized experience. These APIs allow you to list registered passkeys, rename them, and remove them entirely. However registration of passkeys is only supported through the Scalekit UI.
* Node.js
List user's passkeys
```js
// : fetch from Access Token or ID Token after identity verification
const res = await fetch(
'/api/v1/webauthn/credentials?user_id=',
{ headers: { Authorization: 'Bearer ' } }
);
const data = await res.json();
console.log(data);
```
Rename a passkey
```js
// : obtained from list response (id of each passkey)
await fetch('/api/v1/webauthn/credentials/', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer '
},
body: JSON.stringify({ display_name: '' })
});
```
Remove a passkey
```js
// : obtained from list response (id of each passkey)
await fetch('/api/v1/webauthn/credentials/', {
method: 'DELETE',
headers: { Authorization: 'Bearer ' }
});
```
* Python
List user's passkeys
```python
import requests
# : fetch from access token or ID token after identity verification
r = requests.get(
'/api/v1/webauthn/credentials',
params={'user_id': ''},
headers={'Authorization': 'Bearer '}
)
print(r.json())
```
Rename a passkey
```python
import requests
# : obtained from list response (id of each passkey)
requests.patch(
'/api/v1/webauthn/credentials/',
json={'display_name': ''},
headers={'Authorization': 'Bearer '}
)
```
Remove a passkey
```python
import requests
# : obtained from list response (id of each passkey)
requests.delete(
'/api/v1/webauthn/credentials/',
headers={'Authorization': 'Bearer '}
)
```
* Java
List user's passkeys
```java
var client = java.net.http.HttpClient.newHttpClient();
// : fetch from Access Token or ID Token after identity verification
var req = java.net.http.HttpRequest.newBuilder(
java.net.URI.create("/api/v1/webauthn/credentials?user_id=")
)
.header("Authorization", "Bearer ")
.GET().build();
var res = client.send(req, java.net.http.HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
```
Rename a passkey
```java
var client = java.net.http.HttpClient.newHttpClient();
var body = "{\"display_name\":\"\"}";
// : obtained from list response (id of each passkey)
var req = java.net.http.HttpRequest.newBuilder(
java.net.URI.create("/api/v1/webauthn/credentials/")
)
.header("Authorization", "Bearer ")
.header("Content-Type","application/json")
.method("PATCH", java.net.http.HttpRequest.BodyPublishers.ofString(body))
.build();
client.send(req, java.net.http.HttpResponse.BodyHandlers.discarding());
```
Remove a passkey
```java
var client = java.net.http.HttpClient.newHttpClient();
// : obtained from list response (id of each passkey)
var req = java.net.http.HttpRequest.newBuilder(
java.net.URI.create("/api/v1/webauthn/credentials/")
)
.header("Authorization", "Bearer ")
.DELETE().build();
client.send(req, java.net.http.HttpResponse.BodyHandlers.discarding());
```
* Go
List user's passkeys
```go
// imports: net/http, io, fmt
// : fetch from access token or ID token after identity verification
req, _ := http.NewRequest("GET", "/api/v1/webauthn/credentials?user_id=", nil)
req.Header.Set("Authorization", "Bearer ")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b))
```
Rename a passkey
```go
// imports: net/http, bytes
payload := bytes.NewBufferString(`{"display_name":""}`)
// : obtained from list response (id of each passkey)
req, _ := http.NewRequest("PATCH", "/api/v1/webauthn/credentials/", payload)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer ")
http.DefaultClient.Do(req)
```
Remove a passkey
```go
// imports: net/http
// : obtained from list response (id of each passkey)
req, _ := http.NewRequest("DELETE", "/api/v1/webauthn/credentials/", nil)
req.Header.Set("Authorization", "Bearer ")
http.DefaultClient.Do(req)
```
Note
All API requests require an access token obtained via the OAuth 2.0 client credentials flow. Follow [Authenticate with the Scalekit API](/guides/authenticate-scalekit-api), then replace `` in the examples below.
3. ## Users can log in with passkeys
[Section titled “Users can log in with passkeys”](#users-can-log-in-with-passkeys)
Users who have registered passkeys can log in with them.
This time when login page shows, users can select “Passkey” as the authentication method.

During sign-up, you’ll continue to use established authentication methods like [verification codes, magic links](/authenticate/auth-methods/passwordless/) or [social logins](/authenticate/auth-methods/social-logins/). Once a user is registered, they can then add passkeys as an additional, convenient login option.
---
# DOCUMENT BOUNDARY
---
# Sign in with magic link or Email OTP
> Enable passwordless sign-in with email verification codes or magic links
Configure Magic Link & OTP to enable passwordless authentication for your application. After completing the [quickstart guide](/authenticate/fsa/quickstart/), set up email verification codes or magic links so users can sign in without passwords.
Switch between those passwordless methods without modifying any code:
| Method | How it works | Best for |
| ------------------------------ | ---------------------------------------------------------------- | -------------------------------------------- |
| Verification code | Users receive a one-time code via email and enter it in your app | Applications requiring explicit verification |
| Magic link | Users click a link in their email to authenticate | Quick, frictionless sign-in |
| Magic link + Verification code | Users choose either method | Maximum flexibility and user choice |
## Configure magic link or OTP
[Section titled “Configure magic link or OTP”](#configure-magic-link-or-otp)
In the Scalekit dashboard, go to **Authentication > Auth methods > Magic Link & OTP**

1. ### Select authentication method
[Section titled “Select authentication method”](#select-authentication-method)
Choose one of three methods:
* **Verification code** - Users enter a 6-digit code sent to their email
* **Magic link** - Users click a link in their email to authenticate
* **Magic link + Verification code** - Users can choose either method
2. ### Set expiry period
[Section titled “Set expiry period”](#set-expiry-period)
Configure how long verification codes and magic links remain valid:
* **Default**: 300 seconds (5 minutes)
* **Range**: 60 to 3600 seconds
* **Recommendation**: 300 seconds balances security and usability
Note
While shorter expiry periods enhance security by reducing the window for potential unauthorized access, they can negatively impact user experience, especially with shorter email-to-input times. Conversely, longer periods provide more convenience but increase the risk of credential misuse if intercepted.
## Enforce same browser origin
[Section titled “Enforce same browser origin”](#enforce-same-browser-origin)
When enforcing same browser origin, users are required to complete magic link authentication within the same browser where they initiated the login process. This security feature is particularly recommended for applications dealing with sensitive data or financial transactions, as it adds an extra layer of protection against potential unauthorized access attempts.
**Example scenario**: A healthcare app where a user requests a magic link on their laptop. If someone intercepts the email and tries to open it on a different device, the authentication fails.
## Regenerate credentials on resend
[Section titled “Regenerate credentials on resend”](#regenerate-credentials-on-resend)
When a user requests a new Magic Link or Email OTP, the system generates a fresh code or link while automatically invalidating the previous one. This approach is recommended for all applications as a critical security measure to prevent potential misuse of compromised credentials.
**Example scenario**: A user requests a verification code but doesn’t receive it. They request a new code. With this setting enabled, the first code becomes invalid, preventing unauthorized access if the original email was intercepted.
---
# DOCUMENT BOUNDARY
---
# Add social login to your app
> Implement authentication with Google, Microsoft, GitHub, and other social providers
First, complete the [quickstart guide](/authenticate/fsa/quickstart/) to integrate Scalekit auth into your application. Scalekit natively supports OAuth 2.0, enabling you to easily configure social login providers that will automatically appear as authentication options on your login page.
1. ## Configure social login providers
[Section titled “Configure social login providers”](#configure-social-login-providers)
Google login is pre-configured in all development environments for simplified testing. You can integrate additional social login providers by setting up your own connection credentials with each provider.
Navigate to **Authentication** > **Auth Methods** > **Social logins** in your dashboard to configure these settings
### Google
Enable users to sign in with their Google accounts using OAuth 2.0
[Setup →](/guides/integrations/social-connections/google)
### GitHub
Allow users to authenticate using their GitHub credentials
[Setup →](/guides/integrations/social-connections/github)
### Microsoft
Integrate Microsoft accounts for seamless user authentication
[Setup →](/guides/integrations/social-connections/microsoft)
### GitLab
Enable GitLab-based authentication for your application
[Setup →](/guides/integrations/social-connections/gitlab)
### LinkedIn
Let users sign in with their LinkedIn accounts using OAuth 2.0
[Setup →](/guides/integrations/social-connections/linkedin)
### Salesforce
Enable Salesforce-based authentication for your application
[Setup →](/guides/integrations/social-connections/salesforce)
2. ## Test the social connection
[Section titled “Test the social connection”](#test-the-social-connection)
After configuration, test the social connection by clicking on “Test Connection” in the dashboard. You will be redirected to the provider’s consent screen to authorize access. A summary table will show the information that will be sent to your app.

## Access social login options on your login page
[Section titled “Access social login options on your login page”](#access-social-login-options-on-your-login-page)
Your application now supports social logins.
Begin the [login process](/authenticate/fsa/implement-login/) to experience the available social login options. Users can authenticate using providers like Google, GitHub, Microsoft, and any others you have set up.
---
# DOCUMENT BOUNDARY
---
# Assign roles to users
> Learn how to assign roles to users in your application using to dashboard, SDK, or automated provisioning
After registering roles and permissions for your application, Scalekit provides multiple ways to assign roles to users. These roles allow your app to make the access control decisions as scalekit sends them to your app in the access token.
## Auto assign roles as users join organizations
[Section titled “Auto assign roles as users join organizations”](#auto-assign-roles-as-users-join-organizations)
By default, the organization creator automatically receives the `admin` role, while users who join later receive the `member` role. You can customize these defaults to match your application’s security requirements. For instance, in a CRM system, you may want to set the default role for new members to a read-only role like `viewer` to prevent accidental data modifications.
1. Go to **Dashboard** > **Roles & Permissions** > **Roles** tab
2. Select the roles available and choose defaults for organization creator and member

This automatically assigns these roles to every users who joins any organization in your Scalekit environment.
## Set a default role for new organization members
[Section titled “Set a default role for new organization members”](#set-a-default-role-for-new-organization-members)
You can also configure a default role that is automatically assigned to users who join a specific organization. This organization-level setting **overrides** the application-level default role described above, allowing finer-grained control per organization. 
## Let users assign roles to others API
[Section titled “Let users assign roles to others ”](#let-users-assign-roles-to-others-)
Enable organization administrators to manage user roles directly within your application. By building features like “Change role” or “Assign permissions” into your app, you can provide a management experience without requiring administrators to leave your app.
To implement role assignment functionality, follow these essential prerequisites:
1. **Verify administrator permissions**: Ensure the user performing the role assignment has the `admin` role or an equivalent role with the necessary permissions. Check the `permissions` property in their access token to confirm they have role management capabilities.
* Node.js
Verify permissions
```javascript
1
// Decode JWT and check admin permissions
2
const decodedToken = decodeJWT(adminAccessToken);
3
4
// Check if user has admin role or required permissions
5
const isAdmin = decodedToken.roles.includes('admin');
6
const hasPermission = decodedToken.permissions?.includes('users.write') ||
7
decodedToken.permissions?.includes('roles.assign');
8
9
if (!isAdmin && !hasPermission) {
10
throw new Error('Insufficient permissions to assign roles');
11
}
```
* Python
Verify permissions
```python
1
# Decode JWT and check admin permissions
2
decoded_token = decode_jwt(access_token)
3
4
# Check if user has admin role or required permissions
5
is_admin = 'admin' in decoded_token.get('roles', [])
6
has_permission = any(perm in decoded_token.get('permissions', [])
7
for perm in ['users.write', 'roles.assign'])
8
9
if not is_admin and not has_permission:
10
raise PermissionError("Insufficient permissions to assign roles")
```
* Go
Verify permissions
```go
1
// Decode JWT and check admin permissions
2
decodedToken, err := decodeJWT(accessToken)
3
if err != nil {
4
return ValidationResult{Success: false, Error: "Invalid token"}
5
}
6
7
// Check if user has admin role or required permissions
8
roles := decodedToken["roles"].([]interface{})
9
permissions := decodedToken["permissions"].([]interface{})
10
11
isAdmin := false
12
hasPermission := false
13
14
for _, role := range roles {
15
if role == "admin" {
16
isAdmin = true
17
break
18
}
19
}
20
21
for _, perm := range permissions {
22
if perm == "users.write" || perm == "roles.assign" {
23
hasPermission = true
24
break
25
}
26
}
27
28
if !isAdmin && !hasPermission {
29
return ValidationResult{Success: false, Error: "Insufficient permissions"}
30
}
```
* Java
Verify permissions
```java
1
// Decode JWT and check admin permissions
2
Claims decodedToken = decodeJWT(accessToken);
3
4
@SuppressWarnings("unchecked")
5
List userRoles = (List) decodedToken.get("roles");
6
@SuppressWarnings("unchecked")
7
List permissions = (List) decodedToken.get("permissions");
8
9
// Check if user has admin role or required permissions
10
boolean isAdmin = userRoles != null && userRoles.contains("admin");
11
boolean hasPermission = permissions != null &&
12
(permissions.contains("users.write") || permissions.contains("roles.assign"));
13
14
if (!isAdmin && !hasPermission) {
15
throw new SecurityException("Insufficient permissions to assign roles");
16
}
```
2. **Collect required identifiers**: Gather the necessary parameters for the API call:
* `user_id`: The unique identifier of the user whose role you’re changing
* `organization_id`: The organization where the role assignment applies
* `roles`: An array of role names to assign to the user
- Node.js
Collect and validate identifiers
```javascript
1
// Structure and validate role assignment data
2
const roleAssignmentData = {
3
user_id: targetUserId,
4
organization_id: targetOrgId,
5
roles: newRoles,
6
// Additional metadata for auditing
7
performed_by: decodedToken.sub,
8
timestamp: new Date().toISOString()
9
};
10
11
// Validate required fields
12
if (!roleAssignmentData.user_id || !roleAssignmentData.organization_id || !roleAssignmentData.roles) {
13
throw new Error('Missing required identifiers for role assignment');
14
}
```
- Python
Collect and validate identifiers
```python
1
# Structure and validate role assignment data
2
role_assignment_data = {
3
'user_id': target_user_id,
4
'organization_id': target_org_id,
5
'roles': new_roles,
6
# Additional metadata for auditing
7
'performed_by': decoded_token.get('sub'),
8
'timestamp': datetime.utcnow().isoformat()
9
}
10
11
# Validate required fields
12
if not all([role_assignment_data['user_id'],
13
role_assignment_data['organization_id'],
14
role_assignment_data['roles']]):
15
raise ValueError("Missing required identifiers for role assignment")
```
- Go
Collect and validate identifiers
```go
1
// Structure and validate role assignment data
2
roleAssignmentData := map[string]interface{}{
3
"user_id": req.UserID,
4
"organization_id": req.OrganizationID,
5
"roles": req.Roles,
6
// Additional metadata for auditing
7
"performed_by": decodedToken["sub"],
8
"timestamp": time.Now().UTC().Format(time.RFC3339),
9
}
10
11
// Validate required fields
12
if req.UserID == "" || req.OrganizationID == "" || len(req.Roles) == 0 {
13
return ValidationResult{Success: false, Error: "Missing required identifiers"}
14
}
```
- Java
Collect and validate identifiers
```java
1
// Structure and validate role assignment data
2
Map roleAssignmentData = new HashMap<>();
3
roleAssignmentData.put("user_id", request.userId);
4
roleAssignmentData.put("organization_id", request.organizationId);
5
roleAssignmentData.put("roles", request.roles);
6
7
// Additional metadata for auditing
8
roleAssignmentData.put("performed_by", decodedToken.getSubject());
9
roleAssignmentData.put("timestamp", Instant.now().toString());
10
11
// Validate required fields
12
if (request.userId == null || request.organizationId == null || request.roles == null) {
13
throw new IllegalArgumentException("Missing required identifiers for role assignment");
14
}
```
3. **Call Scalekit SDK to update user role**: Use the validated data to make the API call that assigns the new roles to the user through the Scalekit membership update endpoint.
* Node.js
Update user role with Scalekit SDK
```javascript
1
// Use case: Update user membership after validation
2
const validationResult = await prepareRoleAssignment(
3
adminAccessToken,
4
targetUserId,
5
targetOrgId,
6
newRoles
7
);
8
9
if (!validationResult.success) {
10
return res.status(403).json({ error: validationResult.error });
11
}
12
13
// Initialize Scalekit client (reference installation guide for setup)
14
const scalekit = new ScalekitClient(
15
process.env.SCALEKIT_ENVIRONMENT_URL,
16
process.env.SCALEKIT_CLIENT_ID,
17
process.env.SCALEKIT_CLIENT_SECRET
18
);
19
20
// Make the API call to update user roles
21
try {
22
const result = await scalekit.user.updateMembership({
23
user_id: validationResult.data.user_id,
24
organization_id: validationResult.data.organization_id,
25
roles: validationResult.data.roles
26
});
27
28
console.log(`Role assigned successfully:`, result);
29
return res.json({
30
success: true,
31
message: "Role updated successfully",
32
data: result
33
});
34
} catch (error) {
35
console.error(`Failed to assign role: ${error.message}`);
36
return res.status(500).json({
37
error: "Failed to update role",
38
details: error.message
39
});
40
}
```
* Python
Update user role with Scalekit SDK
```python
1
# Use case: Update user membership after validation
2
validation_result = prepare_role_assignment(
3
access_token,
4
target_user_id,
5
target_org_id,
6
new_roles
7
)
8
9
if not validation_result['success']:
10
return jsonify({'error': validation_result['error']}), 403
11
12
# Initialize Scalekit client (reference installation guide for setup)
13
scalekit_client = ScalekitClient(
14
env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"),
15
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
16
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET")
17
)
18
19
# Make the API call to update user roles
20
try:
21
from scalekit.v1.users.users_pb2 import UpdateMembershipRequest
22
23
request = UpdateMembershipRequest(
24
user_id=validation_result['data']['user_id'],
25
organization_id=validation_result['data']['organization_id'],
26
roles=validation_result['data']['roles']
27
)
28
29
result = scalekit_client.users.update_membership(request=request)
30
print(f"Role assigned successfully: {result}")
31
32
return jsonify({
33
'success': True,
34
'message': 'Role updated successfully',
35
'data': str(result)
36
})
37
38
except Exception as error:
39
print(f"Failed to assign role: {error}")
40
return jsonify({
41
'error': 'Failed to update role',
42
'details': str(error)
43
}), 500
```
* Go
Update user role with Scalekit SDK
```go
1
// Use case: Update user membership after validation
2
validationResult := prepareRoleAssignment(ctx, accessToken, req)
3
4
if !validationResult.Success {
5
http.Error(w, validationResult.Error, http.StatusForbidden)
6
return
7
}
8
9
// Initialize Scalekit client (reference installation guide for setup)
10
scalekitClient := scalekit.NewScalekitClient(
11
os.Getenv("SCALEKIT_ENVIRONMENT_URL"),
12
os.Getenv("SCALEKIT_CLIENT_ID"),
13
os.Getenv("SCALEKIT_CLIENT_SECRET"),
14
)
15
16
// Make the API call to update user roles
17
data := validationResult.Data.(map[string]interface{})
18
updateRequest := &scalekit.UpdateMembershipRequest{
19
UserId: data["user_id"].(string),
20
OrganizationId: data["organization_id"].(string),
21
Roles: data["roles"].([]string),
22
}
23
24
result, err := scalekitClient.Membership().UpdateMembership(ctx, updateRequest)
25
if err != nil {
26
log.Printf("Failed to assign role: %v", err)
27
http.Error(w, "Failed to update role", http.StatusInternalServerError)
28
return
29
}
30
31
log.Printf("Role assigned successfully: %+v", result)
32
json.NewEncoder(w).Encode(map[string]interface{}{
33
"success": true,
34
"message": "Role updated successfully",
35
"data": result,
36
})
```
* Java
Update user role with Scalekit SDK
```java
1
// Use case: Update user membership after validation
2
ValidationResult validationResult = prepareRoleAssignment(accessToken, request);
3
4
if (!validationResult.success) {
5
return ResponseEntity.status(403).body(Map.of("error", validationResult.error));
6
}
7
8
// Initialize Scalekit client (reference installation guide for setup)
9
ScalekitClient scalekitClient = new ScalekitClient(
10
System.getenv("SCALEKIT_ENVIRONMENT_URL"),
11
System.getenv("SCALEKIT_CLIENT_ID"),
12
System.getenv("SCALEKIT_CLIENT_SECRET")
13
);
14
15
// Make the API call to update user roles
16
try {
17
@SuppressWarnings("unchecked")
18
Map data = (Map) validationResult.data;
19
20
UpdateMembershipRequest updateRequest = UpdateMembershipRequest.newBuilder()
21
.setUserId((String) data.get("user_id"))
22
.setOrganizationId((String) data.get("organization_id"))
23
.addAllRoles((List) data.get("roles"))
24
.build();
25
26
UpdateMembershipResponse response = scalekitClient.users().updateMembership(updateRequest);
27
System.out.println("Role assigned successfully: " + response);
28
29
return ResponseEntity.ok(Map.of(
30
"success", true,
31
"message", "Role updated successfully",
32
"data", response.toString()
33
));
34
35
} catch (Exception e) {
36
System.err.println("Failed to assign role: " + e.getMessage());
37
return ResponseEntity.status(500).body(Map.of(
38
"error", "Failed to update role",
39
"details", e.getMessage()
40
));
41
}
```
4. **Handle response and provide feedback**: Return appropriate success/error responses to the administrator and update your application’s UI accordingly.
* Node.js
Handle API response
```javascript
1
// Success response handling
2
if (result.success) {
3
// Update UI to reflect role change
4
await updateUserInterface(targetUserId, newRoles);
5
6
// Send notification to user (optional)
7
await notifyUserOfRoleChange(targetUserId, newRoles);
8
9
// Log the action for audit purposes
10
await logRoleChange({
11
performed_by: decodedToken.sub,
12
target_user: targetUserId,
13
organization: targetOrgId,
14
old_roles: previousRoles,
15
new_roles: newRoles,
16
timestamp: new Date().toISOString()
17
});
18
}
```
* Python
Handle API response
```python
1
# Success response handling
2
if result.get('success'):
3
# Update UI to reflect role change
4
await update_user_interface(target_user_id, new_roles)
5
6
# Send notification to user (optional)
7
await notify_user_of_role_change(target_user_id, new_roles)
8
9
# Log the action for audit purposes
10
await log_role_change({
11
'performed_by': decoded_token.get('sub'),
12
'target_user': target_user_id,
13
'organization': target_org_id,
14
'old_roles': previous_roles,
15
'new_roles': new_roles,
16
'timestamp': datetime.utcnow().isoformat()
17
})
```
* Go
Handle API response
```go
1
// Success response handling
2
if success {
3
// Update UI to reflect role change
4
updateUserInterface(targetUserID, newRoles)
5
6
// Send notification to user (optional)
7
notifyUserOfRoleChange(targetUserID, newRoles)
8
9
// Log the action for audit purposes
10
logRoleChange(map[string]interface{}{
11
"performed_by": decodedToken["sub"],
12
"target_user": targetUserID,
13
"organization": targetOrgID,
14
"old_roles": previousRoles,
15
"new_roles": newRoles,
16
"timestamp": time.Now().UTC().Format(time.RFC3339),
17
})
18
}
```
* Java
Handle API response
```java
1
// Success response handling
2
if (response.getBody().containsKey("success") &&
3
Boolean.TRUE.equals(response.getBody().get("success"))) {
4
5
// Update UI to reflect role change
6
updateUserInterface(targetUserId, newRoles);
7
8
// Send notification to user (optional)
9
notifyUserOfRoleChange(targetUserId, newRoles);
10
11
// Log the action for audit purposes
12
logRoleChange(Map.of(
13
"performed_by", decodedToken.getSubject(),
14
"target_user", targetUserId,
15
"organization", targetOrgId,
16
"old_roles", previousRoles,
17
"new_roles", newRoles,
18
"timestamp", Instant.now().toString()
19
));
20
}
```
---
# DOCUMENT BOUNDARY
---
# Create and manage roles and permissions
> Set up roles and permissions to control access in your application
Before writing any code, take a moment to plan your application’s authorization model. A well-designed structure for roles and permissions is crucial for security and maintainability. Start by considering the following questions:
* What are the actions your users can perform?
* How many distinct roles does your application need?
Your application’s use cases will determine the answers. Here are a few common patterns:
* **Simple roles**: Some applications, like an online whiteboarding tool, may only need a few roles with implicit permissions. For example, `Admin`, `Editor`, and `Viewer`. In this case, you might not even need to define granular permissions.
* **Pre-defined roles and permissions**: Many applications have a fixed set of roles built from specific permissions. For a project management tool, you could define permissions like `projects:create` and `tasks:assign`, then group them into roles like `Project Manager` and `Team Member`.
* **Customer-defined Roles**: For complex applications, you might allow organization owners to create custom roles with a specific set of permissions. These roles are specific to an organization rather than global to your application.
Scalekit provides the flexibility to build authorization for any of these use cases. Once you have a clear plan, you can start creating your permissions and roles.
Define the permissions your application needs by registering them with Scalekit. Use the `resource:action` format for clear, self-documenting permission names. You can skip this step, in case permissions may not fit your app’s authorization model.
1. ## Define the actions your users can perform as permissions
[Section titled “Define the actions your users can perform as permissions”](#define-the-actions-your-users-can-perform-as-permissions)
* Node.js
Create permissions
```javascript
9 collapsed lines
1
// Initialize Scalekit client
2
// Use case: Register all available actions in your project management app
3
import { ScalekitClient } from "@scalekit-sdk/node";
4
5
const scalekit = new ScalekitClient(
6
process.env.SCALEKIT_ENVIRONMENT_URL,
7
process.env.SCALEKIT_CLIENT_ID,
8
process.env.SCALEKIT_CLIENT_SECRET
9
);
10
11
// Define your application's permissions
12
const permissions = [
13
{
14
name: "projects:create",
15
description: "Allows users to create new projects"
16
},
17
{
18
name: "projects:read",
19
description: "Allows users to view project details"
20
},
21
{
22
name: "projects:update",
23
description: "Allows users to modify existing projects"
24
},
25
{
26
name: "projects:delete",
27
description: "Allows users to remove projects"
28
},
29
{
30
name: "tasks:assign",
31
description: "Allows users to assign tasks to team members"
32
}
33
];
34
35
// Register each permission with Scalekit
36
for (const permission of permissions) {
37
await scalekit.permission.createPermission(permission);
38
console.log(`Created permission: ${permission.name}`);
39
}
40
41
// Your application's permissions are now registered with Scalekit
```
* Python
Create permissions
```python
12 collapsed lines
1
# Initialize Scalekit client
2
# Use case: Register all available actions in your project management app
3
from scalekit import ScalekitClient
4
5
scalekit_client = ScalekitClient(
6
env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"),
7
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
8
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET")
9
)
10
11
# Define your application's permissions
12
from scalekit.v1.roles.roles_pb2 import CreatePermission
13
14
permissions = [
15
CreatePermission(
16
name="projects:create",
17
description="Allows users to create new projects"
18
),
19
CreatePermission(
20
name="projects:read",
21
description="Allows users to view project details"
22
),
23
CreatePermission(
24
name="projects:update",
25
description="Allows users to modify existing projects"
26
),
27
CreatePermission(
28
name="projects:delete",
29
description="Allows users to remove projects"
30
),
31
CreatePermission(
32
name="tasks:assign",
33
description="Allows users to assign tasks to team members"
34
)
35
]
36
37
# Register each permission with Scalekit
38
for permission in permissions:
39
scalekit_client.permissions.create_permission(permission=permission)
40
print(f"Created permission: {permission.name}")
41
42
# Your application's permissions are now registered with Scalekit
```
* Go
Create permissions
```go
17 collapsed lines
1
// Initialize Scalekit client
2
// Use case: Register all available actions in your project management app
3
package main
4
5
import (
6
"context"
7
"log"
8
"github.com/scalekit-inc/scalekit-sdk-go"
9
)
10
11
func main() {
12
sc := scalekit.NewScalekitClient(
13
os.Getenv("SCALEKIT_ENVIRONMENT_URL"),
14
os.Getenv("SCALEKIT_CLIENT_ID"),
15
os.Getenv("SCALEKIT_CLIENT_SECRET"),
16
)
17
18
// Define your application's permissions
19
permissions := []*scalekit.CreatePermission{
20
{
21
Name: "projects:create",
22
Description: "Allows users to create new projects",
23
},
24
{
25
Name: "projects:read",
26
Description: "Allows users to view project details",
27
},
28
{
29
Name: "projects:update",
30
Description: "Allows users to modify existing projects",
31
},
32
{
33
Name: "projects:delete",
34
Description: "Allows users to remove projects",
35
},
36
{
37
Name: "tasks:assign",
38
Description: "Allows users to assign tasks to team members",
39
},
40
}
41
42
// Register each permission with Scalekit
43
for _, permission := range permissions {
44
_, err := sc.Permission().CreatePermission(ctx, permission)
45
if err != nil {
46
log.Printf("Failed to create permission: %s", permission.Name)
47
continue
48
}
49
fmt.Printf("Created permission: %s\n", permission.Name)
50
}
51
52
// Your application's permissions are now registered with Scalekit
53
}
```
* Java
Create permissions
```java
11 collapsed lines
1
// Initialize Scalekit client
2
// Use case: Register all available actions in your project management app
3
import com.scalekit.ScalekitClient;
4
import com.scalekit.grpc.scalekit.v1.roles.*;
5
6
ScalekitClient scalekitClient = new ScalekitClient(
7
System.getenv("SCALEKIT_ENVIRONMENT_URL"),
8
System.getenv("SCALEKIT_CLIENT_ID"),
9
System.getenv("SCALEKIT_CLIENT_SECRET")
10
);
11
12
// Define your application's permissions
13
List permissions = Arrays.asList(
14
CreatePermission.newBuilder()
15
.setName("projects:create")
16
.setDescription("Allows users to create new projects")
17
.build(),
18
CreatePermission.newBuilder()
19
.setName("projects:read")
20
.setDescription("Allows users to view project details")
21
.build(),
22
CreatePermission.newBuilder()
23
.setName("projects:update")
24
.setDescription("Allows users to modify existing projects")
25
.build(),
26
CreatePermission.newBuilder()
27
.setName("projects:delete")
28
.setDescription("Allows users to remove projects")
29
.build(),
30
CreatePermission.newBuilder()
31
.setName("tasks:assign")
32
.setDescription("Allows users to assign tasks to team members")
33
.build()
34
);
35
36
// Register each permission with Scalekit
37
for (CreatePermission permission : permissions) {
38
try {
39
CreatePermissionRequest request = CreatePermissionRequest.newBuilder()
40
.setPermission(permission)
41
.build();
42
43
scalekitClient.permissions().createPermission(request);
44
System.out.println("Created permission: " + permission.getName());
45
} catch (Exception e) {
46
System.err.println("Error creating permission: " + e.getMessage());
47
}
48
}
49
50
// Your application's permissions are now registered with Scalekit
```
2. ## Register roles your applications will use
[Section titled “Register roles your applications will use”](#register-roles-your-applications-will-use)
Once you have defined permissions, group them into roles that match your application’s access patterns.
* Node.js
Create roles with permissions
```javascript
1
// Define roles with their associated permissions
2
// Use case: Create standard roles for your project management application
3
const roles = [
4
{
5
name: 'project_admin',
6
display_name: 'Project Administrator',
7
description: 'Full access to manage projects and team members',
8
permissions: [
9
'projects:create', 'projects:read', 'projects:update', 'projects:delete',
10
'tasks:assign'
11
]
12
},
13
{
14
name: 'project_manager',
15
display_name: 'Project Manager',
16
description: 'Can manage projects and assign tasks',
17
permissions: [
18
'projects:create', 'projects:read', 'projects:update',
19
'tasks:assign'
20
]
21
},
22
{
23
name: 'team_member',
24
display_name: 'Team Member',
25
description: 'Can view projects and participate in tasks',
26
permissions: [
27
'projects:read'
28
]
29
}
30
];
31
32
// Register each role with Scalekit
33
for (const role of roles) {
34
await scalekit.role.createRole(role);
35
console.log(`Created role: ${role.name}`);
36
}
37
38
// Your application's roles are now registered with Scalekit
```
* Python
Create roles with permissions
```python
1
# Define roles with their associated permissions
2
# Use case: Create standard roles for your project management application
3
from scalekit.v1.roles.roles_pb2 import CreateRole
4
5
roles = [
6
CreateRole(
7
name="project_admin",
8
display_name="Project Administrator",
9
description="Full access to manage projects and team members",
10
permissions=["projects:create", "projects:read", "projects:update", "projects:delete", "tasks:assign"]
11
),
12
CreateRole(
13
name="project_manager",
14
display_name="Project Manager",
15
description="Can manage projects and assign tasks",
16
permissions=["projects:create", "projects:read", "projects:update", "tasks:assign"]
17
),
18
CreateRole(
19
name="team_member",
20
display_name="Team Member",
21
description="Can view projects and participate in tasks",
22
permissions=["projects:read"]
23
)
24
]
25
26
# Register each role with Scalekit
27
for role in roles:
28
scalekit_client.roles.create_role(role=role)
29
print(f"Created role: {role.name}")
30
31
# Your application's roles are now registered with Scalekit
```
* Go
Create roles with permissions
```go
1
// Define roles with their associated permissions
2
// Use case: Create standard roles for your project management application
3
roles := []*scalekit.CreateRole{
4
{
5
Name: "project_admin",
6
DisplayName: "Project Administrator",
7
Description: "Full access to manage projects and team members",
8
Permissions: []string{"projects:create", "projects:read", "projects:update", "projects:delete", "tasks:assign"},
9
},
10
{
11
Name: "project_manager",
12
DisplayName: "Project Manager",
13
Description: "Can manage projects and assign tasks",
14
Permissions: []string{"projects:create", "projects:read", "projects:update", "tasks:assign"},
15
},
16
{
17
Name: "team_member",
18
DisplayName: "Team Member",
19
Description: "Can view projects and participate in tasks",
20
Permissions: []string{"projects:read"},
21
},
22
}
23
24
// Register each role with Scalekit
25
for _, role := range roles {
26
_, err := sc.Role().CreateRole(ctx, role)
27
if err != nil {
28
log.Printf("Failed to create role: %s", role.Name)
29
continue
30
}
31
fmt.Printf("Created role: %s\n", role.Name)
32
}
33
34
// Your application's roles are now registered with Scalekit
```
* Java
Create roles with permissions
```java
1
// Define roles with their associated permissions
2
// Use case: Create standard roles for your project management application
3
List roles = Arrays.asList(
4
CreateRole.newBuilder()
5
.setName("project_admin")
6
.setDisplayName("Project Administrator")
7
.setDescription("Full access to manage projects and team members")
8
.addAllPermissions(Arrays.asList("projects:create", "projects:read", "projects:update", "projects:delete", "tasks:assign"))
9
.build(),
10
CreateRole.newBuilder()
11
.setName("project_manager")
12
.setDisplayName("Project Manager")
13
.setDescription("Can manage projects and assign tasks")
14
.addAllPermissions(Arrays.asList("projects:create", "projects:read", "projects:update", "tasks:assign"))
15
.build(),
16
CreateRole.newBuilder()
17
.setName("team_member")
18
.setDisplayName("Team Member")
19
.setDescription("Can view projects and participate in tasks")
20
.addPermissions("projects:read")
21
.build()
22
);
23
24
// Register each role with Scalekit
25
for (CreateRole role : roles) {
26
try {
27
CreateRoleRequest request = CreateRoleRequest.newBuilder()
28
.setRole(role)
29
.build();
30
31
scalekitClient.roles().createRole(request);
32
System.out.println("Created role: " + role.getName());
33
} catch (Exception e) {
34
System.err.println("Error creating role: " + e.getMessage());
35
}
36
}
37
38
// Your application's roles are now registered with Scalekit
```
## Inherit permissions through roles
[Section titled “Inherit permissions through roles”](#inherit-permissions-through-roles)
Large applications with extensive feature sets require sophisticated role and permission management. Scalekit enables role inheritance, allowing you to create a hierarchical access control system. Permissions can be grouped into roles, and new roles can be derived from existing base roles, providing a flexible and scalable approach to defining user access.
Role assignment in Scalekit automatically grants a user all permissions defined within that role.
This is how you can implement use it:
1. Your app defines the permissions and assigns to a role. Let’s say `viewer` role.
2. When creating new role called `editor`, you specify that it inherits the permissions from the `viewer` role.
3. When creating new role called `project_owner`, you specify that it inherits the permissions from the `editor` role.
Take a look at our [Roles and Permissions APIs](https://docs.scalekit.com/apis/#tag/roles/GET/api/v1/roles).
## Manage roles and permissions in the dashboard
[Section titled “Manage roles and permissions in the dashboard”](#manage-roles-and-permissions-in-the-dashboard)
For most applications, the simplest way to create and manage roles and permissions is through the Scalekit dashboard. This approach works well when you have a fixed set of roles and permissions that don’t need to be modified by users in your application. You can set up your authorization model once during application configuration and manage it through the dashboard going forward.

1. Navigate to **Dashboard** > **Roles & Permissions** > **Permissions** to create permissions:
* Click **Create Permission** and provide:
* **Name** - Machine-friendly identifier (e.g., `projects:create`)
* **Display Name** - Human-readable label (e.g., “Create Projects”)
* **Description** - Clear explanation of what this permission allows
2. Go to **Dashboard** > **Roles & Permissions** > **Roles** to create roles:
* Click **Create Role** and provide:
* **Name** - Machine-friendly identifier (e.g., `project_manager`)
* **Display Name** - Human-readable label (e.g., “Project Manager”)
* **Description** - Clear explanation of the role’s purpose
* **Permissions** - Select the permissions to include in this role
3. Configure default roles for new users who join organizations
4. Organization administrators can create organization-specific roles by going to **Dashboard** > **Organizations** > **Select organization** > **Roles**
Now that you have created roles and permissions in Scalekit, the next step is to assign these roles to users in your application.
### Configure organization specific roles
[Section titled “Configure organization specific roles”](#configure-organization-specific-roles)
Organization-level roles let organization administrators create custom roles that apply only within their specific organization. These roles are separate from any application-level roles you define.

You can create organization-level roles from the Scalekit Dashboard:
* Go to **Organizations → Select an organization → Roles**
* In **Organization roles** section, Click **+ Add role** and provide:
* **Display name**: Human-readable name (e.g., “Manager”)
* **Name (key)**: Machine-friendly identifier (e.g., `manager`)
* **Description**: Clear explanation of what users with this role can do
---
# DOCUMENT BOUNDARY
---
# Implement access control
> Verify permissions and roles in your application code to control user access
After configuring permissions and roles, the next critical step is implementing access control directly within your application code. This is achieved by carefully examining the roles and permissions embedded in the user’s access token to make authorization decisions.
Scalekit conveniently packages these authorization details during the authentication process, providing you with a comprehensive set of data to make precise access control decisions without requiring additional API calls.
Review the authorization flow
This section focuses on implementing access control, which naturally follows user authentication. We recommend completing the authentication [quickstart](/authenticate/fsa/quickstart) before diving into these access control implementation details.
## Start by inspecting the access token
[Section titled “Start by inspecting the access token”](#start-by-inspecting-the-access-token)
When you [exchange the code for a user profile](/authenticate/fsa/complete-login/), Scalekit also adds additional information that help your app determine the access control decisions.
* Auth result
```js
1
{
2
user: {
3
email: "john.doe@example.com",
4
emailVerified: true,
5
givenName: "John",
6
name: "John Doe",
7
id: "usr_74599896446906854"
8
},
9
idToken: "eyJhbGciO..", // Decode for full user details
10
11
accessToken: "eyJhbGciOi..",
12
refreshToken: "rt_8f7d6e5c4b3a2d1e0f9g8h7i6j..",
13
expiresIn: 299 // in seconds
14
}
```
* Decoded ID token
ID token decoded
```json
1
{
2
"at_hash": "ec_jU2ZKpFelCKLTRWiRsg",
3
"aud": [
4
"skc_58327482062864390"
5
],
6
"azp": "skc_58327482062864390",
7
"c_hash": "6wMreK9kWQQY6O5R0CiiYg",
8
"client_id": "skc_58327482062864390",
9
"email": "john.doe@example.com",
10
"email_verified": true,
11
"exp": 1742975822,
12
"family_name": "Doe",
13
"given_name": "John",
14
"iat": 1742974022,
15
"iss": "https://scalekit-z44iroqaaada-dev.scalekit.cloud",
16
"name": "John Doe",
17
"oid": "org_59615193906282635",
18
"sid": "ses_65274187031249433",
19
"sub": "usr_63261014140912135"
20
}
```
* Decoded access token
Decoded access token
```json
1
{
2
"aud": [
3
"prd_skc_7848964512134X699"
4
],
5
"client_id": "prd_skc_7848964512134X699",
6
"exp": 1758265247,
7
"iat": 1758264947,
8
"iss": "https://login.devramp.ai",
9
"jti": "tkn_90928731115292X63",
10
"nbf": 1758264947,
11
"oid": "org_89678001X21929734",
12
"permissions": [
13
"workspace_data:write",
14
"workspace_data:read"
15
],
16
"roles": [
17
"admin"
18
],
19
"sid": "ses_90928729571723X24",
20
"sub": "usr_8967800122X995270",
21
// External identifiers if updated on Scalekit
22
"xoid": "ext_org_123", // Organization ID
23
"xuid": "ext_usr_456", // User ID
24
}
```
Let’s closely look at the access token:
Decoded access token
```json
{
"aud": ["skc_987654321098765432"],
"client_id": "skc_987654321098765432",
"exp": 1750850145,
"iat": 1750849845,
"iss": "http://example.localhost:8889",
"jti": "tkn_987654321098765432",
"nbf": 1750849845,
"roles": ["project_manager", "member"],
"oid": "org_69615647365005430",
"permissions": ["projects:create", "projects:read", "projects:update", "tasks:assign"],
"sid": "ses_987654321098765432",
"sub": "usr_987654321098765432"
}
```
The `roles` and `permissions` values provide runtime insights into the user’s access constraints directly within the access token, eliminating the need for additional API requests. Crucially, always validate the token’s integrity before relying on the embedded authorization details.
* Node.js
Validate and decode access token in middleware
```javascript
1
// Middleware to validate tokens and extract authorization data
2
const validateAndExtractAuth = async (req, res, next) => {
3
try {
4
// Extract access token from cookie (decrypt if needed)
5
const accessToken = decrypt(req.cookies.accessToken);
6
7
// Validate the token using Scalekit SDK
8
const isValid = await scalekit.validateAccessToken(accessToken);
9
10
if (!isValid) {
11
return res.status(401).json({ error: 'Invalid or expired token' });
12
}
13
14
// Decode token to get roles and permissions using any JWT decode library
15
const tokenData = await decodeAccessToken(accessToken);
16
17
// Make authorization data available to route handlers
18
req.user = {
19
id: tokenData.sub,
20
organizationId: tokenData.oid,
21
roles: tokenData.roles || [],
22
permissions: tokenData.permissions || []
23
};
24
25
next();
26
} catch (error) {
27
return res.status(401).json({ error: 'Authentication failed' });
28
}
29
};
```
* Python
Validate and decode access token
```python
4 collapsed lines
1
from scalekit import ScalekitClient
2
from functools import wraps
3
import jwt
4
5
scalekit_client = ScalekitClient(/* your credentials */)
6
7
def validate_and_extract_auth(f):
8
@wraps(f)
9
def decorated_function(*args, **kwargs):
10
try:
11
# Extract access token from cookie (decrypt if needed)
12
access_token = decrypt(request.cookies.get('accessToken'))
13
14
# Validate the token using Scalekit SDK
15
is_valid = scalekit_client.validate_access_token(access_token)
16
17
if not is_valid:
18
return jsonify({'error': 'Invalid or expired token'}), 401
19
20
# Decode token to get roles and permissions
21
token_data = scalekit_client.decode_access_token(access_token)
22
23
# Make authorization data available to route handlers
24
request.user = {
25
'id': token_data.get('sub'),
26
'organization_id': token_data.get('oid'),
27
'roles': token_data.get('roles', []),
28
'permissions': token_data.get('permissions', [])
29
}
30
31
return f(*args, **kwargs)
32
except Exception as e:
33
return jsonify({'error': 'Authentication failed'}), 401
34
35
return decorated_function
```
* Go
Validate and decode access token
```go
7 collapsed lines
1
import (
2
"context"
3
"encoding/json"
4
"net/http"
5
"github.com/scalekit-inc/scalekit-sdk-go"
6
)
7
8
scalekitClient := scalekit.NewScalekitClient(/* your credentials */)
9
10
func validateAndExtractAuth(next http.HandlerFunc) http.HandlerFunc {
11
return func(w http.ResponseWriter, r *http.Request) {
12
// Extract access token from cookie (decrypt if needed)
13
cookie, err := r.Cookie("accessToken")
14
if err != nil {
15
http.Error(w, `{"error": "No access token provided"}`, http.StatusUnauthorized)
16
return
17
}
18
19
accessToken, err := decrypt(cookie.Value)
20
if err != nil {
21
http.Error(w, `{"error": "Token decryption failed"}`, http.StatusUnauthorized)
22
return
23
}
24
25
// Validate the token using Scalekit SDK
26
isValid, err := scalekitClient.ValidateAccessToken(r.Context(), accessToken)
27
if err != nil || !isValid {
28
http.Error(w, `{"error": "Invalid or expired token"}`, http.StatusUnauthorized)
29
return
30
}
31
32
// Decode token to get roles and permissions using any JWT decode lib
33
tokenData, err := DecodeAccessToken(accessToken)
34
if err != nil {
35
http.Error(w, `{"error": "Token decode failed"}`, http.StatusUnauthorized)
36
return
37
}
38
39
// Add authorization data to request context
40
user := map[string]interface{}{
41
"id": tokenData["sub"],
42
"organization_id": tokenData["oid"],
43
"roles": tokenData["roles"],
44
"permissions": tokenData["permissions"],
45
}
46
47
ctx := context.WithValue(r.Context(), "user", user)
48
next(w, r.WithContext(ctx))
49
}
50
}
```
* Java
Validate and decode access token
```java
7 collapsed lines
1
import com.scalekit.ScalekitClient;
2
import javax.servlet.http.HttpServletRequest;
3
import javax.servlet.http.HttpServletResponse;
4
import org.springframework.web.servlet.HandlerInterceptor;
5
import java.util.Map;
6
import java.util.HashMap;
7
8
@Component
9
public class AuthorizationInterceptor implements HandlerInterceptor {
10
private final ScalekitClient scalekit;
11
12
@Override
13
public boolean preHandle(
14
HttpServletRequest request,
15
HttpServletResponse response,
16
Object handler
17
) throws Exception {
18
try {
19
// Extract access token from cookie (decrypt if needed)
20
String accessToken = getCookieValue(request, "accessToken");
21
String decryptedToken = decrypt(accessToken);
22
23
// Validate the token using Scalekit SDK
24
boolean isValid = scalekit.authentication().validateAccessToken(decryptedToken);
25
26
if (!isValid) {
27
response.setStatus(HttpStatus.UNAUTHORIZED.value());
28
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
29
return false;
30
}
31
32
// Decode token to get roles and permissions using any JWT decode lib
33
Map tokenData = decodeAccessToken(decryptedToken);
34
35
// Make authorization data available to controllers
36
Map user = new HashMap<>();
37
user.put("id", tokenData.get("sub"));
38
user.put("organizationId", tokenData.get("oid"));
39
user.put("roles", tokenData.get("roles"));
40
user.put("permissions", tokenData.get("permissions"));
41
42
request.setAttribute("user", user);
43
return true;
44
45
} catch (Exception e) {
46
response.setStatus(HttpStatus.UNAUTHORIZED.value());
47
response.getWriter().write("{\"error\": \"Authentication failed\"}");
48
return false;
49
}
50
}
51
}
```
This approach makes user roles and permissions available throughout different routes of your application, enabling consistent and secure access control across all endpoints.
## Verify user’s role to allow access to protected resources
[Section titled “Verify user’s role to allow access to protected resources”](#verify-users-role-to-allow-access-to-protected-resources)
Role-based access control (RBAC) provides a straightforward way to manage permissions by grouping them into logical roles. Instead of checking individual permissions for every action, your application can simply verify if the user has the required role, making access control decisions more efficient and easier to maintain.
Tip
Use roles for broad access control patterns like admin access, management privileges, or user tiers. Reserve permissions for fine-grained control over specific actions and resources.
* Node.js
Role-based access control
```javascript
17 collapsed lines
1
// Helper function to check roles
2
function hasRole(user, requiredRole) {
3
return user.roles && user.roles.includes(requiredRole);
4
}
5
6
// Middleware to require specific roles
7
function requireRole(role) {
8
return (req, res, next) => {
9
if (!hasRole(req.user, role)) {
10
return res.status(403).json({
11
error: `Access denied. Required role: ${role}`
12
});
13
}
14
next();
15
};
16
}
17
18
// Admin-only routes
19
app.get('/api/admin/users', validateAndExtractAuth, requireRole('admin'), (req, res) => {
20
// Only admin users can access this endpoint
21
res.json(getAllUsers(req.user.organizationId));
22
});
23
24
// Multiple role check
25
app.post('/api/admin/invite-user', validateAndExtractAuth, (req, res) => {
26
const user = req.user;
27
28
// Allow admins or managers to invite users
29
if (!hasRole(user, 'admin') && !hasRole(user, 'manager')) {
30
return res.status(403).json({ error: 'Only admins and managers can invite users' });
31
}
32
33
const invitation = createUserInvitation(req.body, user.organizationId);
34
res.json(invitation);
35
});
```
* Python
Role-based access control
```python
17 collapsed lines
1
# Helper function to check roles
2
def has_role(user, required_role):
3
roles = user.get('roles', [])
4
return required_role in roles
5
6
# Decorator to require specific roles
7
def require_role(role):
8
def decorator(f):
9
@wraps(f)
10
def decorated_function(*args, **kwargs):
11
user = getattr(request, 'user', {})
12
if not has_role(user, role):
13
return jsonify({'error': f'Access denied. Required role: {role}'}), 403
14
return f(*args, **kwargs)
15
return decorated_function
16
return decorator
17
18
# Admin-only routes
19
@app.route('/api/admin/users')
20
@validate_and_extract_auth
21
@require_role('admin')
22
def get_all_users():
23
# Only admin users can access this endpoint
24
return jsonify(get_all_users_for_org(request.user['organization_id']))
25
26
# Multiple role check
27
@app.route('/api/admin/invite-user', methods=['POST'])
28
@validate_and_extract_auth
29
def invite_user():
30
user = request.user
31
32
# Allow admins or managers to invite users
33
if not has_role(user, 'admin') and not has_role(user, 'manager'):
34
return jsonify({'error': 'Only admins and managers can invite users'}), 403
35
36
invitation = create_user_invitation(request.json, user['organization_id'])
37
return jsonify(invitation)
```
* Go
Role-based access control
```go
31 collapsed lines
1
// Helper function to check roles
2
func hasRole(user map[string]interface{}, requiredRole string) bool {
3
roles, ok := user["roles"].([]interface{})
4
if !ok {
5
return false
6
}
7
8
for _, role := range roles {
9
if roleStr, ok := role.(string); ok && roleStr == requiredRole {
10
return true
11
}
12
}
13
return false
14
}
15
16
// Middleware to require specific roles
17
func requireRole(role string) func(http.HandlerFunc) http.HandlerFunc {
18
return func(next http.HandlerFunc) http.HandlerFunc {
19
return func(w http.ResponseWriter, r *http.Request) {
20
user := r.Context().Value("user").(map[string]interface{})
21
22
if !hasRole(user, role) {
23
http.Error(w, fmt.Sprintf(`{"error": "Access denied. Required role: %s"}`, role), http.StatusForbidden)
24
return
25
}
26
27
next(w, r)
28
}
29
}
30
}
31
32
// Admin-only routes
33
func getAllUsersHandler(w http.ResponseWriter, r *http.Request) {
34
user := r.Context().Value("user").(map[string]interface{})
35
orgId := user["organization_id"].(string)
36
37
// Only admin users can access this endpoint
38
users := getAllUsersForOrg(orgId)
39
json.NewEncoder(w).Encode(users)
40
}
41
42
// Route setup with role middleware
43
http.HandleFunc("/api/admin/users", validateAndExtractAuth(requireRole("admin")(getAllUsersHandler)))
```
* Java
Role-based access control
```java
1
@RestController
2
public class AdminController {
7 collapsed lines
3
4
// Helper method to check roles
5
private boolean hasRole(Map user, String requiredRole) {
6
List roles = (List) user.get("roles");
7
return roles != null && roles.contains(requiredRole);
8
}
9
10
// Admin-only endpoint
11
@GetMapping("/api/admin/users")
12
public ResponseEntity> getAllUsers(HttpServletRequest request) {
13
Map