Docs
Search docs

Bind a repository to a GitHub App installation

POST/api/repos/{owner}/{repo}/github-installation

bindGithubInstallation

Pins this repository to a GitHub App installation and to GitHub's **immutable numeric** repository id. A rename or transfer upstream moves the `owner/name` locator and keeps the id, which is why the id is what is stored. Three locks, all before any row is written: the caller must hold repo **admin**; the caller must be allowed to use the installation; and the repository must really be inside that installation, proved by a single `GET /repositories/{id}` under the installation's own token. A failure at any of them writes nothing. Who may use an installation depends on what it is installed on. A **user** installation is usable by the account that connected it. An **organisation** installation is usable by an Admin or Owner of the organisation workspace its org claim created, and only for that workspace's own repositories — the policy layer's `workspace:integration:manage` decision on the repository's workspace. Any other case — an installation nobody connected, someone else's, an org installation on a repo outside the org, a workspace Member, an insufficient token — is one and the same `403`, whether or not the installation exists: the difference would be an existence oracle. **The `repo:write` scope alone is not enough.** For a user installation the credential must also reach the account surface (`user:read`, no resource lock), because the binding is decided against *which installations this account connected*. For an organisation installation the credential must be allowed the workspace action on the repository's workspace — so a token locked to a repository, locked to another workspace, or carrying only `repo:*` is refused. A session or an unrestricted token of an account that may passes. Idempotent on the exact same triple. Binding a repository that is already bound to a *different* GitHub repository is `409` — the provider identity is immutable — as is binding a GitHub repository that another repository already claims. Health starts `unverified`. **Both ids or neither** (one without the other is `400`). With neither, the server finds them — "Connect GitHub App": the GitHub repository is this repository's GitHub relationship (`400` when it has none), the installation is one you may use that reaches it (the same per-installation decision as above, asked before any token is minted for it), and the id is GitHub's answer for it under that installation's token. `403 github_app_install_required` (its `message` carries the install URL) when no such installation exists. The pair found is then bound through the same three locks. 404 when this deployment has no GitHub App configured.

Requires authentication using a bearer token or a session cookie — see tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/repos/OWNER/REPO/github-installation' \
  -H 'Authorization: Bearer $NEOSOURCE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{}'

fetch

fetch("https://neosource.dev/api/repos/OWNER/REPO/github-installation", {
  method: "POST",
  headers: {
    Authorization: "Bearer $NEOSOURCE_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({}),
});

Path parameters

ownerrequired

Repository owner or organization slug.

string

reporequired

Repository name.

string

Request bodyrequired

application/json

BindGithubInstallationRequest

object

Bind a repository to one of the caller's installations — both ids, or neither for the server to find them (`POST …/github-installation`).

app_installation_id
one of
  • GithubInstallationId

    integerint64

    GitHub's App installation id (`installation.id` on every App webhook). Nothing is rejected at the type level; the `> 0` invariant is a database CHECK, because the only values that reach here come from GitHub.

  • null

github_repository_id
one of
  • GithubRepositoryId

    integerint64

    GitHub's immutable numeric repository id (`repository.id`). Distinct from [`GithubInstallationId`] on purpose: both are `bigint`, and a swapped argument would otherwise type-check and bind the wrong repository.

  • null

Responses

201Binding created

application/json

GithubAppBindingResponse

object

A repository's App binding.

app_installation_idrequired

GithubInstallationId

integerint64

GitHub's App installation id (`installation.id` on every App webhook). Nothing is rejected at the type level; the `> 0` invariant is a database CHECK, because the only values that reach here come from GitHub.

created_atrequired

integerint64

Epoch milliseconds.

github_repository_idrequired

GithubRepositoryId

integerint64

GitHub's immutable numeric repository id (`repository.id`). Distinct from [`GithubInstallationId`] on purpose: both are `bigint`, and a swapped argument would otherwise type-check and bind the wrong repository.

healthrequired

GithubAppBindingHealth

string

Health of a repo's App binding (`github_app_bindings.health`). Mirrors the Phase 0a CHECK exactly; a new variant needs a migration.

"unverified""healthy""insufficient_scope""ruleset_unverified""revoked""error"

last_health_checked_at

integer | nullint64

Epoch milliseconds of the last health probe.

ruleset_audit_outcome
one of
  • RulesetAuditOutcome

    string

    What the last ruleset audit of a binding found (`github_app_bindings.ruleset_audit_outcome`, migration `20260928120000_github_binding_ruleset_audit.sql`). [`Self::Clear`] is the one outcome that makes a binding `Healthy`; every other value records WHY it is `ruleset_unverified`. The audit fails closed: anything it cannot prove — a field GitHub did not report, a list it may not read, a rule whose ruleset it cannot see — is a refusal, never an assumed "the App cannot bypass" (`plans/github-managed-repositories-2026-09.md` Phase 2; evidence `docs/evidence/github-managed/provider-contract-2026-09-27-175844.md`).

    "clear""app_can_bypass""bypass_unknown""rulesets_unreadable""ruleset_hidden""too_many_rulesets""classic_protection_unreadable""app_in_classic_allowance""app_holds_administration_write""protection_events_not_subscribed"

  • null

ruleset_audited_at

integer | nullint64

Epoch milliseconds of the last ruleset audit.

token_expires_at

integer | nullint64

Epoch milliseconds at which the last installation token minted for this binding expires. The token itself is never stored or returned.

updated_atrequired

integerint64

Epoch milliseconds.

Standard errors

Bodies documented once for the whole API — see standard errors.

  • 400Bad Request — one of: invalid_input
  • 401Authentication required
  • 403Forbidden — one of: forbidden, github_app_install_required
  • 404Not Found — one of: not_found
  • 409Conflict — one of: conflict
  • 429Rate limited — retry after the `Retry-After` header
  • 500Internal server error
  • 502Bad Gateway — one of: upstream
  • 503Service temporarily unavailable / at capacity — retry after the `Retry-After` header
  • 504Gateway timeout — the request exceeded the server's handling budget