- 01.Problem
- 02.Solutions
- 03.Solution 1: Installation Webhook (Recommended)
- 04.Solution 2: Direct API Lookup (Simpler, Good Fallback)
- 05.Solution 3: Enable "Redirect on Update"
- 06.Recommendation for Post-v1
- 07.References
GitHub App Reinstallation Callback Issue
When a user installs the GitHub App but our callback fails (network error, state expiry, etc.), the installation exists on GitHub but not in our database. If the user tries to connect again:
Date: 2025-01-07
Status: Documented for post-v1
Files: apps/web/src/app/api/github/callback/route.ts, packages/trpc/src/router/github/index.ts
Problem
When a user installs the GitHub App but our callback fails (network error, state expiry, etc.), the installation exists on GitHub but not in our database. If the user tries to connect again:
- They're sent to
https://github.com/apps/{slug}/installations/new?state={state} - GitHub sees app is already installed → shows "Configure installation" page
- No redirect occurs unless user actually changes something (adds/removes repos)
- User is stuck on GitHub with no way back
This is a known GitHub limitation - there's no "always redirect" parameter.
Solutions
Solution 1: Installation Webhook (Recommended)
Set up the installation webhook so we receive installation events regardless of callback success.
GitHub App Settings → Webhooks:
- Subscribe to:
installationevents - Events:
created,deleted,suspend,unsuspend
Implementation:
// apps/web/src/app/api/github/webhook/route.ts
import { Webhooks } from '@octokit/webhooks';
import { db } from '@onbook/db';
import { githubInstallations } from '@onbook/db/schema';
import { eq } from 'drizzle-orm';
const webhooks = new Webhooks({
secret: process.env.GITHUB_WEBHOOK_SECRET!,
});
webhooks.on('installation.created', async ({ payload }) => {
const { installation, sender } = payload;
// Store as "pending" - no org association yet
// User will claim it when they complete onboarding
await db.insert(githubInstallations).values({
installationId: installation.id,
accountId: installation.account.id,
accountLogin: installation.account.login,
accountType: installation.account.type,
accountAvatarUrl: installation.account.avatar_url,
organizationId: null, // Pending - will be claimed
userId: null,
installedBy: sender.login, // Track who installed
}).onConflictDoUpdate({
target: githubInstallations.installationId,
set: {
accountLogin: installation.account.login,
accountAvatarUrl: installation.account.avatar_url,
updatedAt: new Date(),
deletedAt: null,
},
});
});
webhooks.on('installation.deleted', async ({ payload }) => {
await db.update(githubInstallations)
.set({ deletedAt: new Date() })
.where(eq(githubInstallations.installationId, payload.installation.id));
});
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-hub-signature-256')!;
await webhooks.verifyAndReceive({
id: request.headers.get('x-github-delivery')!,
name: request.headers.get('x-github-event') as any,
signature,
payload: body,
});
return new Response('OK');
}Schema change needed:
// Add to githubInstallations table
installedBy: varchar('installed_by', { length: 255 }), // GitHub username who installed
// Make organizationId nullable for pending installations
organizationId: varchar('organization_id', { length: 255 }), // Remove .notNull()Claiming flow:
// New endpoint: github.claimInstallation
claimInstallation: protectedProcedure
.input(z.object({ installationId: z.number() }))
.mutation(async ({ ctx, input }) => {
// Verify installation exists and is unclaimed
const installation = await db.query.githubInstallations.findFirst({
where: and(
eq(githubInstallations.installationId, input.installationId),
isNull(githubInstallations.organizationId),
),
});
if (!installation) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
// Claim it for current org
await db.update(githubInstallations)
.set({
organizationId: ctx.auth.session.activeOrganizationId,
userId: ctx.auth.user.id,
})
.where(eq(githubInstallations.installationId, input.installationId));
}),Solution 2: Direct API Lookup (Simpler, Good Fallback)
GitHub provides direct lookup endpoints - no need to paginate all installations:
| Endpoint | Purpose |
|---|---|
GET /orgs/{org}/installation | Look up by GitHub org name |
GET /users/{username}/installation | Look up by GitHub username |
Returns installation details or 404. Requires JWT auth (as the App).
Implementation:
// packages/trpc/src/router/github/index.ts
/**
* Look up installation by GitHub org/username
* Used when callback failed but user already installed
*/
syncInstallation: protectedProcedure
.input(z.object({
accountLogin: z.string(),
accountType: z.enum(['Organization', 'User']),
}))
.mutation(async ({ ctx, input }) => {
const octokit = await getAppOctokit();
try {
// Direct lookup - no pagination needed
const endpoint = input.accountType === 'Organization'
? octokit.apps.getOrgInstallation({ org: input.accountLogin })
: octokit.apps.getUserInstallation({ username: input.accountLogin });
const { data } = await endpoint;
// Save/update installation
await db.insert(githubInstallations).values({
installationId: data.id,
accountId: data.account?.id ?? 0,
accountLogin: data.account?.login ?? input.accountLogin,
accountType: input.accountType,
accountAvatarUrl: data.account?.avatar_url ?? null,
organizationId: ctx.auth.session.activeOrganizationId,
userId: ctx.auth.user.id,
}).onConflictDoUpdate({
target: githubInstallations.installationId,
set: {
organizationId: ctx.auth.session.activeOrganizationId,
userId: ctx.auth.user.id,
updatedAt: new Date(),
deletedAt: null,
},
});
return { success: true, installationId: data.id };
} catch (error) {
if ((error as any).status === 404) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'GitHub App not installed on this account',
});
}
throw error;
}
}),UI for fallback:
// In ConnectStep.tsx - add "Already connected?" section
const [showSync, setShowSync] = useState(false);
const [accountLogin, setAccountLogin] = useState('');
const syncMutation = trpc.github.syncInstallation.useMutation();
// Show after user returns from GitHub without callback
{showSync && (
<div className="mt-4 p-4 border rounded">
<p className="text-sm text-muted-foreground mb-2">
Already installed the app? Enter your GitHub organization or username:
</p>
<div className="flex gap-2">
<Input
value={accountLogin}
onChange={(e) => setAccountLogin(e.target.value)}
placeholder="acme-corp or username"
/>
<Button
onClick={() => syncMutation.mutate({
accountLogin,
accountType: 'Organization', // Could add toggle
})}
disabled={syncMutation.isPending}
>
Sync
</Button>
</div>
</div>
)}Solution 3: Enable "Redirect on Update"
In GitHub App settings, enable "Redirect on update" checkbox. This redirects users to your Setup URL after they modify the installation (add/remove repos).
Limitation: Doesn't help if user makes no changes - they still get stuck.
Location: GitHub → Settings → Developer settings → GitHub Apps → [Your App] → General → Post installation
Recommendation for Post-v1
- Implement webhook solution - most robust, catches all installations
- Add direct lookup as fallback UI - helps users who are stuck
- Enable "Redirect on update" - free improvement, enable now