How install works
A user grants your bot some repositories and the permissions you requested. That grant is the install. It is not a collaborator seat and not a personal access token.
What the user does
- Opens
/integrationsor your listing URL (/integrations/{your-org}/{your-bot}). - Clicks Install, picks one account, then repositories they administer on that account. Personal and org grants are separate installs. Mixed accounts in one submit are rejected.
- If you set a valid Setup URL, the browser is sent there with
?installation_id=…. Otherwise they go to Settings → Connections.
If One install + Sign in screen is on (and the bot has a linked OAuth app), that install page also lists Sign in with open-git identity scopes. Continuing records OAuth consent at the same time. It does not log them into your product — your Setup URL still starts the authorize request so you can store PKCE and receive tokens. The second consent page is skipped because consent already exists. Turn the toggle off if the bot does not need a customer identity.
If this account already installed you, we add the newly selected repos
instead of creating a second install. You get installation.repos_changed,
not installation.created. The original installer is not overwritten. If
that install is already connected (external_customer_id), we skip Setup
URL and send them to Connections instead — identity is already linked.
They can change the repo set later, or uninstall, from:
- Settings → Connections (
/settings/account/connections) /integrations/installations/{id}- Organization settings → Integrations (org-owned repos)
/{owner}/{repo}/settings/integrations(Remove from this repo — if other repos remain, the installation id stays the same and you getinstallation.repos_changedwith the remaining ids. Removing the last repo uninstalls and sendsinstallation.deleted.)
A repo admin can remove your bot from their repo even if they cannot manage the whole install. Removing the last repo uninstalls. Uninstall from Connections or Manage also revokes every remaining repo at once.
Connect (the button on Connections and the manage page) is shown when the
install is active, the bot is still published, and the Setup URL is a valid
absolute URL. “Connected” means you wrote an external_customer_id via
link.
What you should do
When the webhook arrives, or when the browser hits Setup URL (or when you GET the installation):
- If you do not know this person, send them through Sign in with open-git.
- Save
open_git_user_id(sub) andinstallation_idon your customer. - Optionally tell open-git they are connected (below).
- Start handling pull-request events for that install.
If they never finish connect or never pay, the install still exists. You choose not to run. Do not expect open-git to disable it for you.
type StoredInstall = {
id: string
repositoryIds: string[]
openGitUserId: string | null
entitled: boolean // your flag — open-git does not store this
}
// From the webhook (you may not know the user yet)
async function upsertInstallFromWebhook(payload: {
installation_id?: string
repositories?: unknown
}) {
if (typeof payload.installation_id !== "string") {
return
}
// Install events send repository UUIDs, not owner/name.
const repositoryIds = Array.isArray(payload.repositories)
? payload.repositories.filter((id): id is string => typeof id === "string")
: []
await db.installs.upsert({
id: payload.installation_id,
repositoryIds,
})
}
// After Sign in with open-git succeeds
async function attachCustomerToInstall(options: {
installationId: string | null
openGitUserId: string
}) {
if (!options.installationId) {
return
}
await db.installs.update(options.installationId, {
openGitUserId: options.openGitUserId,
})
await db.customers.upsert({
openGitUserId: options.openGitUserId,
installationId: options.installationId,
})
}
db.installs / db.customers are your store.
Show Connected on open-git
After you know the customer, you can write an id back so Connections shows “connected”:
import { createBotAppClient } from "@opengit/sdk"
async function botApp(installationId: string) {
return createBotAppClient({
baseUrl: process.env.OPEN_GIT_URL,
botId: process.env.OPEN_GIT_BOT_ID!,
installationId,
privateKey: process.env.OPEN_GIT_BOT_PRIVATE_KEY!.replace(/\\n/g, "\n"),
})
}
async function markConnected(installationId: string, customerId: string) {
const app = await botApp(installationId)
await app.linkInstallation(installationId, customerId)
}
async function markDisconnected(installationId: string) {
const app = await botApp(installationId)
await app.linkInstallation(installationId, null)
}
Same request by hand:
POST /api/v1/app/installations/{id}/link
Authorization: Bearer <bot JWT or ogi_ token for this install>
Content-Type: application/json
{ "external_customer_id": "cus_123" }
external_customer_id must be a string or null. Send null to clear it.
This is a label in our UI, not a billing switch. See
Calling the API.
Read the current grant without waiting on a webhook:
GET /api/v1/app/installations/{id}
Authorization: Bearer <bot JWT or ogi_ token for this install>
app.getInstallation(id) returns repositories as { id, owner, name }.
Connect is just your Setup URL plus installation_id again — the same OAuth
hop as after a fresh install.
app.get("/connect", async (req, res) => {
const installationId =
typeof req.query.installation_id === "string"
? req.query.installation_id
: null
res.redirect(await beginSignIn({ installationId }))
})
Forks and deleted repos
A fork is a new repository. Installing on the source repo does not grant the fork. The user must install you there if they want you on the fork.
If they delete a granted repository, we notify you with
installation.repos_changed and the remaining repository ids. Drop the
deleted one from your store.
if (event === "installation.repos_changed") {
await db.installs.update(payload.installation_id, {
repositoryIds: payload.repositories,
})
}
if (event === "installation.deleted") {
await db.installs.markDeleted(payload.installation_id)
}
installation.deleted includes installation_id only (plus action).
After they uninstall
You receive installation.deleted. Stop calling the API for that id. History
(old comments and checks) stays on the pull request.