Route files: app/api/install/*/route.ts
Supporting lib: lib/auth/server/installation-service.ts, lib/supabase/installation-repository.ts
GET /api/install/start
File: app/api/install/start/route.ts
Requires an active session. If you’re not signed in, it redirects to /api/auth/start?returnTo=/api/install/start first.
| Param | Type | Default | Description |
|---|---|---|---|
returnTo | string | / | Where to go after install |
targetId | number | — | Pre-select a specific GitHub org/user by database ID |
targetType | string | — | "organization" or "user" |
Steps
getRequestSession(request)→ session lookup- No session → redirect to
/api/auth/start - Generate CSRF (32 bytes base64url random)
- Create signed install state JWT (HS256):
{ type: "install", csrf, returnTo, sessionId }, expires 10 min - Build GitHub install URL
- Set
gh_install_csrfcookie: httpOnly, secure, sameSite=none, path=/, maxAge=600 - 302 redirect
Install state JWT
interface InstallState {
type: 'install';
csrf: string;
returnTo: string;
sessionId: string; // Embedded so callback can find session even if cookie is lost
}Edge cases
- Session expires during the install flow: the
sessionIdin the state JWT lets the callback still find it. - If the app is already installed, GitHub shows a “Configure” page instead. The callback still processes it.
GET /api/install/callback
File: app/api/install/callback/route.ts
GitHub redirects here after the user installs (or configures) the app.
| Param | Required | Description |
|---|---|---|
installation_id | yes | GitHub App installation ID (integer) |
setup_action | no | Usually "install" |
state | yes | Signed install state JWT |
Steps
- State verification: verify HS256 signature + expiry +
type === "install". Fail → 400 - CSRF validation:
gh_install_csrfcookie must equalstate.csrf. Fail → 403 - Installation ID validation:
Number.parseInt→ must be finite. Fail → 400 - Validate installation:
InstallationService.validateInstallation(id)calls GitHub API to confirm it exists. Fail → 400 - Session resolution: try
getSession(state.sessionId)first, fall back togetRequestSession(request). Fail → 401 - Merge installation ID: insert into
session_installationsjoin table if not already present - 302 redirect to
returnTo. Deletegh_install_csrf
Error responses
| Status | Condition | Message |
|---|---|---|
| 400 | Missing state or installation_id | ”Something went wrong during app installation. Please try again.” |
| 400 | Invalid/expired state JWT | ”Your installation session was invalid. Please try installing again.” |
| 403 | CSRF mismatch | ”Your installation session expired. Please try installing again.” |
| 400 | installation_id not a number | ”GitHub returned an invalid installation ID. Please try installing again.” |
| 401 | No session found | ”Your session expired during installation. Please sign in and try again.” |
| 500 | Processing error | ”Installation failed. Please try again.” |
GET /api/install/status
File: app/api/install/status/route.ts
Requires session. Returns 401 if missing.
Response (with installations)
{
"installed": true,
"installationIds": [12345, 67890],
"accounts": [
{
"installationId": 12345,
"accountLogin": "my-org",
"accountType": "organization",
"repositoryCount": 18,
"repositories": [
{
"nameWithOwner": "my-org/repo1",
"url": "https://github.com/my-org/repo1",
"isPrivate": false
}
],
"updatedAt": "2026-05-25T10:00:00.000Z"
}
],
"summary": {
"totalInstallations": 2,
"orgInstallations": 1,
"totalRepositories": 18,
"totalAccounts": 1,
"organizationAccounts": 1,
"userAccounts": 0
}
}Response (no installations)
{
"installed": false,
"installationIds": [],
"accounts": [],
"summary": {
"totalInstallations": 0,
"orgInstallations": 0,
"totalRepositories": 0,
"totalAccounts": 0,
"organizationAccounts": 0,
"userAccounts": 0
}
}How it works
For each installation ID in the session:
- Call
InstallationService.getInstallationRepositories(id) - Uses installation-scoped Octokit to fetch repository data
- Returns snapshot with account info, repo count, and repository list
POST /api/install/complete
File: app/api/install/complete/route.ts
Requires session. 401 if missing.
Request body
{ "installationId": 12345 }Response
{ "ok": true, "installationId": 12345 }Logic:
- Validate
installationIdis a finite number - Validate installation exists via GitHub API
- Insert into
session_installationsjoin table (if not already present) - Update session in Supabase
| Status | Condition |
|---|---|
| 400 | Invalid/missing installationId |
| 401 | No session |
| 404 | Installation not found or not accessible |
Webhook-driven updates
The POST /api/install/webhook endpoint (see Webhook API) handles GitHub App events and updates github_installations automatically:
- New installs appear in the DB immediately
- Deletes remove the row immediately
- Suspensions update the status immediately
- Repository changes refresh the installation metadata
The GET /api/install/status endpoint reads from this cached data rather than calling GitHub on every request.
Related
- Webhook API — GitHub App webhook handler
- Organization Routes — org list and detail
- Auth Routes — OAuth sign-in flow
- Database Schema —
github_installationstable