neosourceDocs
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. Four locks, all before any row is written: the credential must have **account reach**; the caller must hold repo **admin**; the installation must be one **this account** connected; 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. An installation this account did not connect is `403` whether or not it exists — the difference would be an existence oracle. **The `repo:write` scope alone is not enough.** A resource-locked token (scoped to one repository or one workspace) and a token carrying only `repo:*` are both refused with `403`, even for a caller who is a repository admin. The binding is durable, account-scoped state — it is decided against *which installations this account connected* — so the credential must also reach the account surface (`user:read`), which a resource-locked token deliberately does not. A session or an unrestricted token 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`. 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 '{"app_installation_id":"string","github_repository_id":"string"}'

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({"app_installation_id":"string","github_repository_id":"string"}),
});

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.

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.

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.

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.

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.

403Forbidden — one of: forbidden, needs_scope

application/json

one of
  • ErrorForbidden
  • NeedsScopeError

    object

    `403` body returned when listing private repos but the linked identity lacks the required provider scope. The SPA turns this into an incremental-authorization prompt (Tier 2) that calls the `/elevate` OAuth endpoint with this `scope`.

    errorrequired

    string

    Always `needs_scope` — this body exists to carry the extra fields that kind needs.

    "needs_scope"

    scoperequired

    string

    The provider scope to request via elevation (e.g. `"repo"`).

Standard errors

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

  • 400Bad Request — one of: invalid_input
  • 401Authentication required
  • 404Not Found — one of: not_found
  • 409Conflict — one of: already_exists, conflict
  • 423Locked — one of: busy
  • 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