API Reference
Status: Authoritative API reference.
Authentication
Section titled “Authentication”The API supports three authentication models:
Bearer token (dashboard): Supabase session access token via Authorization: Bearer header. Used by the dashboard UI.
Authorization: Bearer <supabase-session-token>Admin (dashboard console): The same Supabase Bearer token, but the session’s
user must have the admin role (app_metadata.role === "admin"). A legacy
X-Admin-Key fallback remains for operator tooling.
Authorization: Bearer <supabase-session-token> # admin role requiredX-Admin-Key: <admin-key> # legacy fallbackRemoved: the
X-API-Keyheader and the/api/v1/tenants/:id/*endpoints (the original surface) have been removed — the dashboard uses Supabase Bearer sessions and no tenant API keys are issued today.
Error format
Section titled “Error format”All error responses use a consistent envelope:
{ "error": { "code": "invalid_key", "message": "Invalid API key" }}Some errors carry extra fields alongside code and message — field on a
validation failure, retryable on a transient one, portal where the fix is in
the billing portal. Clients should read error.code and ignore what they do not
recognise.
Until recently this page documented the unwrapped shape, which described only the authentication failures. Every other error in the API used the envelope above. Auth now matches the rest, and this documents what all of them actually return (
IMPL-6).
Error codes
Section titled “Error codes”| Code | HTTP Status | Description |
|---|---|---|
unauthorized |
401 | No Bearer token or X-API-Key provided |
missing_auth |
401 | Bearer token or X-API-Key required |
auth_unavailable |
503 | Auth backend did not answer. Retry the request. |
invalid_key |
401 | API key does not match any tenant |
invalid_token |
401 | Invalid or expired Supabase session token |
not_found |
404 | Resource not found |
invalid_body |
400 | Malformed request body |
db_error |
500 | Database error |
repo_already_managed |
409 | Repo already managed by another tenant on the same instance |
connection_identity_already_used |
409 | Forge identity belongs to another tenant |
failed_to_create_connection |
500 | Connection creation failed |
connection_rotation_failed |
500 | Existing connection credential could not be updated |
decryption_error |
500 | Could not decrypt credential |
forge_unreachable |
502 | Could not reach the forge instance |
rate_limited |
429 | Too many requests |
missing_auth means the request carried no credential. auth_unavailable
means the request carried a credential that could not be verified because the
auth backend did not answer. The credential is never accepted in that case.
The response carries retryable: true. A auth_backend_verify_failed log line
records the endpoint and the upstream status. The log never contains the token.
Dashboard endpoints (Bearer auth)
Section titled “Dashboard endpoints (Bearer auth)”These endpoints require a valid Supabase session token.
GET /api/v1/connections
Section titled “GET /api/v1/connections”List the authenticated user’s forge connections. Token fields are never returned.
GET /api/v1/connectionsAuthorization: Bearer <token>Response (200):
[ { "id": "660e8400-...", "tenant_id": "550e8400-...", "instance_url": "https://git.example.com", "bot_username": "gondolier", "token_type": "pat", "created_at": "2026-08-04T12:00:00Z" }]POST /api/v1/connections
Section titled “POST /api/v1/connections”Create a new forge connection. Token fields are never returned.
Identity collision: (instance_url, bot_username) is globally unique. Submitting the same identity from its owning tenant rotates its encrypted token atomically and returns HTTP 200. A different tenant receives HTTP 409 without learning the existing connection ID.
POST /api/v1/connectionsAuthorization: Bearer <token>Content-Type: application/json
{ "instance_url": "https://git.example.com", "token": "ghp_xxxxxxxxxxxx", "bot_login": "gondolier", "forge_type": "forgejo"}Response (201 — new):
{ "id": "660e8400-...", "tenant_id": "550e8400-...", "instance_url": "https://git.example.com", "bot_username": "gondolier", "token_type": "pat", "provider": "forgejo", "created_at": "2026-08-04T12:00:00Z"}Response (200 — same-tenant credential rotation):
{ "rotated": true, "id": "660e8400-...", "provider": "forgejo"}Response (409 — identity belongs to another tenant):
{ "error": { "code": "connection_identity_already_used" }}PATCH /api/v1/connections/:id
Section titled “PATCH /api/v1/connections/:id”Rotate the connection’s token and/or update the bot username. The new token is encrypted at rest. Returns the connection without the token field.
PATCH /api/v1/connections/:idAuthorization: Bearer <token>Content-Type: application/json
{ "token": "ghp_new_token_xxx", // optional; omit to keep current "bot_login": "gondolier" // optional; omit to keep current}Response (200):
{ "id": "660e8400-...", "instance_url": "https://git.example.com", "bot_username": "gondolier", "token_type": "pat", "updated_at": "2026-08-04T13:00:00Z"}DELETE /api/v1/connections/:id
Section titled “DELETE /api/v1/connections/:id”Remove a forge connection. All repos managed under this connection are cascade-deleted.
DELETE /api/v1/connections/:idAuthorization: Bearer <token>Response (204): empty body
POST /api/v1/connections/:id/discover
Section titled “POST /api/v1/connections/:id/discover”List repositories accessible by this connection’s stored credential (decrypted server-side only). The token is never returned or logged.
POST /api/v1/connections/:id/discoverAuthorization: Bearer <token>Response (200):
{ "repos": [ { "full_name": "owner/repo", "name": "repo", "owner": "owner", "html_url": "https://git.example.com/owner/repo", "private": false, "default_branch": "main" } ]}| Status | Meaning |
|---|---|
400 forge_error |
The forge instance returned an error (status and reason included in message) |
502 forge_unreachable |
The forge instance could not be reached |
500 decryption_error |
Could not decrypt the stored credential |
GET /api/v1/user/repos
Section titled “GET /api/v1/user/repos”List the authenticated user’s managed repositories with the complete safe view. Includes connection details, wake mode, provisioning status, and reconcile state.
GET /api/v1/user/reposAuthorization: Bearer <token>Response (200):
[ { "id": "770e8400-...", "name": "owner/repo", "base_branch": "main", "status_context": "gondolier", "merge_style": "squash", "automerge": false, "instance_url": "https://git.example.com", "forge_conn_id": "660e8400-...", "wakeup_mode": "poll", "connection": { "bot_username": "gondolier", "instance_url": "https://git.example.com", "token_type": "pat" }, "provisioning": { "webhook": { "status": "not_required" }, "protection": { "status": "configured", "id": "..." }, "last_provisioned_at": "2026-01-01T00:00:00Z" }, "reconcile": { "ran_at": "2026-01-01T00:00:00Z", "ok": true, "error": null, "error_code": null }, "created_at": "2026-08-04T12:00:00Z", "updated_at": "2026-08-04T13:00:00Z", "hold_reason": null, "queue_enabled": true, "batch_linger_seconds": 0, "bisect_fanout": 1, "batch_target": 0 }]Complete field reference:
| Field | Type | Description |
|---|---|---|
id |
string | Unique repo identifier |
name |
string | Repository slug (owner/repo) |
base_branch |
string | Base branch for the queue |
status_context |
string | CI status context (default gondolier) |
merge_style |
string | Merge strategy: merge, squash, rebase |
automerge |
boolean | Whether Forgejo auto-merge is enabled |
instance_url |
string | Forge instance URL |
forge_conn_id |
string | Foreign key to forge_connections |
wakeup_mode |
string | poll (cron) or webhook (low-latency) |
connection |
object or null | Connection details (token fields stripped) |
provisioning |
object | Provisioning and CI-gate state |
provisioning.webhook.status |
string | configured, not_required, needs_admin, not_configured, error |
provisioning.protection.status |
string | configured, awaiting_ci_gate, needs_admin, not_configured, error |
provisioning.protection.detail |
string or null | Why protection is pending or could not be configured |
provisioning.ci_gate.status |
string | missing, setup_creating, setup_pr_open, awaiting_validation, failed, or verified |
provisioning.ci_gate.url |
string or null | Safe URL for the generated setup PR or observed Actions run |
provisioning.last_provisioned_at |
string or null | ISO timestamp of last provisioning attempt |
reconcile.ran_at |
string or null | Last reconcile run timestamp |
reconcile.ok |
boolean or null | Whether the last reconcile succeeded |
reconcile.error |
string or null | Error message on failure |
reconcile.error_code |
string or null | Machine-readable error code on failure |
created_at |
string | Record creation timestamp |
updated_at |
string | Record update timestamp |
hold_reason |
string or null | Why this repo is not being processed, or null |
queue_enabled |
boolean | Whether the queue is enabled for this repo |
batch_linger_seconds |
integer | Wait time (seconds) after a PR becomes ready before staging |
bisect_fanout |
integer | Number of concurrent CI runs per batch (1–16) |
batch_target |
integer | Number of PRs to stage in one batch (0 = no target) |
POST /api/v1/user/repos
Section titled “POST /api/v1/user/repos”Enroll a new repository in the merge queue.
Provide an existing connection_id owned by the authenticated tenant. Inline forge credentials are not accepted by this endpoint; create a connection first.
POST /api/v1/user/reposAuthorization: Bearer <token>Content-Type: application/json
{ "connection_id": "660e8400-...", "repo_slug": "owner/repo", "base_branch": "main", "status_context": "gondolier", "merge_style": "squash", "automerge": false, "wakeup_mode": "poll"}Response (201 — created):
{ "id": "770e8400-...", "name": "owner/repo", "base_branch": "main", "status_context": "gondolier", "merge_style": "squash", "automerge": false, "instance_url": "https://git.example.com", "forge_conn_id": "660e8400-...", "provisioning": { "webhook": { "status": "not_required" }, "protection": { "status": "configured" }, "last_provisioned_at": "2026-01-01T00:00:00Z" }}Response (409 — duplicate):
{ "error": { "code": "repo_already_managed", "message": "Repo owner/repo is already managed on this forge instance", "existing_tenant_id": "550e8400-...", "existing_managed_repo_id": "770e8400-..." }}Response (400 — missing fields):
{ "error": { "code": "missing_fields", "message": "connection_id required" }}Response (402 — repo limit exceeded):
{ "error": { "code": "repo_limit_exceeded", "message": "free tier allows 1 managed repo (1 in use)" }}Default values:
| Field | Default |
|---|---|
base_branch |
main |
status_context |
gondolier |
merge_style |
squash |
automerge |
false |
wakeup_mode |
poll |
Provisioning: Enrollment triggers best-effort webhook registration in webhook wake mode. Branch protection is configured after the CI gate is verified, so it cannot block the setup PR. CI-gate bootstrap is a separate explicit dashboard action; it can copy a fail-closed workflow or open a normal target-repository setup PR. Failures do not block enrollment — per-item statuses surface in the dashboard.
Bounce notifications
Section titled “Bounce notifications”Set notification on POST /api/v1/user/repos or PATCH /api/v1/user/repos/:id. All three destinations are optional; null turns
notifications off.
{ "notification": { "email": "ops@example.com", "slack_webhook_url": "https://hooks.slack.com/services/…", "discord_webhook_url": "https://discord.com/api/webhooks/…" }}Webhook URLs are restricted to Slack and Discord hosts over HTTPS. The
Worker POSTs to whatever is stored here, and an incoming-webhook URL is
otherwise an arbitrary outbound destination — so anything else is refused with
400 invalid_field at the write boundary rather than failing silently inside a
reconcile tick. Unrecognised keys are dropped rather than stored.
Delivery is best effort: a failed send is logged and never blocks a reconcile. Notifications fire when a pull request is bounced — the queue identified it as the cause of a batch failure and removed it, leaving it open for you to fix.
RESEND_API_KEY must be configured for email; without it the email destination
logs and does nothing. NOTIFICATION_FROM overrides the from-address, which
must be a domain Resend has verified.
PATCH /api/v1/user/repos/:id
Section titled “PATCH /api/v1/user/repos/:id”Tune a managed repository. Every field is optional; send only what changes. Returns the full safe view.
PATCH /api/v1/user/repos/:idAuthorization: Bearer <token>Content-Type: application/json
{ "wakeup_mode": "webhook", "queue_enabled": true, "batch_linger_seconds": 30, "batch_target": 5, "bisect_fanout": 4, "status_context": "gondolier", "notification": { "email": "ops@example.com", "slack_webhook_url": "https://hooks.slack.com/services/…" }}| Field | Type | Meaning |
|---|---|---|
wakeup_mode |
"poll" | "webhook" |
How the repository becomes due. See how long until a PR is picked up — a webhook changes when a repository becomes due, not how fast a due one is handled |
queue_enabled |
boolean | Turn the queue off for this repository without unenrolling it. Disabling cancels any auto-merges already in flight first |
batch_linger_seconds |
integer ≥ 0 | Wait this long after a PR becomes ready before staging, so more PRs join the same batch. 0 stages immediately |
batch_target |
integer ≥ 0 | Stop lingering once this many PRs are ready. 0 means no target |
bisect_fanout |
integer 1–16 | Number of concurrent CI runs per batch. Below 1 would stall the queue; the ceiling is abuse containment |
status_context |
string | The status check name Gondolier posts, and the required check it configures. Default gondolier |
notification |
object or null |
Bounce notifications. All three destinations are optional; null turns notifications off. See bounce notifications below |
Error responses (specific to disabling the queue):
| Status | Meaning |
|---|---|
409 queue_locked |
The repository queue is currently busy (e.g. another disable or reconcile is in progress) |
503 queue_lock_failed |
Could not acquire the lease to disable the queue |
Rejects an out-of-range or wrong-typed value with 400 invalid_field naming the
field rather than silently ignoring it.
Switching to
webhookruns provisioning automatically to register the webhook. Changingstatus_contexton a protected branch updates the required check, keeping exactly one queue context so the old name is not left behind.
Response (200): full repo safe view (same as GET /api/v1/user/repos).
DELETE /api/v1/user/repos/:id
Section titled “DELETE /api/v1/user/repos/:id”Remove a managed repository.
DELETE /api/v1/user/repos/:idAuthorization: Bearer <token>Response (204): empty body
GET /api/v1/user/repos/:id/ci-gate/template
Section titled “GET /api/v1/user/repos/:id/ci-gate/template”Returns the copyable, fail-closed .forgejo/workflows/gondolier-gate.yaml template. The user must replace its exit 1 stub with the repository’s real validation command.
GET /api/v1/user/repos/:id/ci-gate/templateAuthorization: Bearer <token>POST /api/v1/user/repos/:id/ci-gate/setup-pr
Section titled “POST /api/v1/user/repos/:id/ci-gate/setup-pr”Creates a normal target-repository PR adding the fail-closed gate workflow. It reads the configured base SHA, refuses to overwrite an existing gate file, creates a non-gondolier/** setup branch, and never writes the base branch or enables auto-merge.
POST /api/v1/user/repos/:id/ci-gate/setup-prAuthorization: Bearer <token>Response (201): a safe ci_gate object with the setup PR number and URL. Returns 409 ci_gate_exists if the base already contains the gate file.
POST /api/v1/user/repos/:id/ci-gate/verify
Section titled “POST /api/v1/user/repos/:id/ci-gate/verify”Creates a one-file commit on an isolated gondolier/<base>/verify-... ref, deletes that ref, and observes the Gondolier gate push run for the exact probe commit SHA. It never queues or merges a PR. Call again while the run is pending; a failed run remains visible, and a later explicit call creates a new probe. It reports verified only after a successful run.
POST /api/v1/user/repos/:id/ci-gate/verifyAuthorization: Bearer <token>Before creating a probe, the workflow-file read must succeed. A failed read creates no probe and changes no saved verification state.
| Status | Meaning |
|---|---|
409 ci_gate_missing |
Forgejo returned 404 for the workflow file. Check repository access and whether the workflow exists on the base branch. |
422 forge_access_denied |
Forgejo returned 401 or 403 while reading the workflow file or an existing probe’s runs. Check the stored credential and its repository permissions. |
502 forge_unreachable |
A read failed for another reason. This does not establish that the workflow is missing. |
These responses contain no upstream response body or credential. A forge access failure does not invalidate the user’s Gondolier session. Existing successful verification and exact-SHA probe checks do not change. Rollback: revert the response classification and these docs. No migration or new configuration is required.
POST /api/v1/user/repos/:id/protect
Section titled “POST /api/v1/user/repos/:id/protect”Opt in to branch protection. Gondolier does not protect a base branch at enrollment: doing so changes how every pull request in that repository merges, so it is an explicit action.
{ "enabled": true }Adds the repository’s status context (default gondolier) as a required check
on the base branch, and records the opt-in so later provisioning keeps it
configured. Until then, provisioning.protection.status is not_requested.
{"enabled": false} stops Gondolier managing protection. It deliberately
does not remove a rule that already exists on the forge — silently reopening a
base branch would leave no signal that it had happened. Remove it in your
forge’s settings if you want pull requests to merge directly again.
| Status | Meaning |
|---|---|
200 |
{ "protection_requested": bool, "provisioning": {…} } |
404 not_found |
Repository not found or not owned by this tenant |
404 connection_not_found |
The connection row for this repository could not be found |
500 db_error |
Branch protection changed but could not be recorded in the database |
502 forge_unreachable |
Could not configure protection on the forge |
POST /api/v1/user/repos/:id/provision
Section titled “POST /api/v1/user/repos/:id/provision”Re-run provisioning for a repository. Re-attempts webhook registration and branch protection setup; it does not create or verify the target repository’s Forgejo Actions gate.
POST /api/v1/user/repos/:id/provisionAuthorization: Bearer <token>Response (200):
{ "provisioning": { "webhook": { "status": "configured" }, "protection": { "status": "configured" }, "last_provisioned_at": "2026-01-01T00:00:00Z" }}| Status | Meaning |
|---|---|
404 not_found |
Repository not found or not owned by this tenant |
404 connection_not_found |
The connection row for this repository could not be found |
POST /api/v1/user/repos/:id/reconcile
Section titled “POST /api/v1/user/repos/:id/reconcile”Trigger a manual reconcile for a repository. Runs the merge queue algorithm immediately and persists the outcome.
POST /api/v1/user/repos/:id/reconcileAuthorization: Bearer <token>Response (200 — success):
{ "reconcile": { "ran_at": "2026-01-01T00:00:00Z", "ok": true, "error": null }, "log_tail": [ "2026-01-01T00:00:00Z [INFO] queued", "2026-01-01T00:00:01Z [INFO] merged" ]}Response (500 — failure):
{ "reconcile": { "ran_at": "2026-01-01T00:00:00Z", "ok": false, "error": "reconcile failed" }, "log_tail": []}Response (402 — payment required):
{ "error": { "code": "payment_required", "message": "This repository is paused because a payment is outstanding. Update your payment method to resume it.", "portal": "/api/v1/billing/portal" }}Response (409 — queue not ready):
{ "error": { "code": "queue_not_ready", "message": "Enable Gondolier before reconciling" }}GET /api/v1/user/queue/status
Section titled “GET /api/v1/user/queue/status”Per-repo queue health snapshot. Returns the complete safe view for each repo plus live lease state.
GET /api/v1/user/queue/statusAuthorization: Bearer <token>Response (200):
{ "queue_name": "gondolier", "repos": [ { "id": "770e8400-...", "name": "owner/repo", "base_branch": "main", "status_context": "gondolier", "merge_style": "squash", "automerge": false, "instance_url": "https://git.example.com", "forge_conn_id": "660e8400-...", "wakeup_mode": "poll", "connection": { "bot_username": "gondolier", "instance_url": "https://git.example.com", "token_type": "pat" }, "provisioning": { "webhook": { "status": "not_required" }, "protection": { "status": "configured" }, "last_provisioned_at": "2026-01-01T00:00:00Z" }, "reconcile": { "ran_at": "2026-01-01T00:00:00Z", "ok": true, "error": null, "error_code": null }, "lease_state": "idle" } ]}| Field | Type | Description |
|---|---|---|
queue_name |
string | Queue name (usually “gondolier”) |
repos[] |
array | Per-repo health snapshot |
repos[].lease_state |
string | idle, held, or unknown |
| (other fields) | — | Same complete safe view as GET /api/v1/user/repos |
POST /api/v1/user/queue/ws/ticket
Section titled “POST /api/v1/user/queue/ws/ticket”Mint a short-lived ticket for the realtime queue socket.
{ "ticket": "<64 hex characters>", "expires_in": 30 }Browsers cannot set headers on a WebSocket handshake, so something has to
travel in the URL. This used to be the Supabase access token — a long-lived
credential granting full tenant API access, written into access logs, proxy
logs, browser history, and Referer on every dashboard load.
A ticket is the smallest thing that can safely live there. It is opaque, valid for 30 seconds, usable once, and bound to a single tenant; the socket consults the ticket for identity and never the redeemer’s. Losing one to a log buys an attacker a socket that emits refresh pings, and only if they find it within 30 seconds and before the browser uses it.
503 realtime_unavailable when ticket storage is not configured. The dashboard
falls back to its 30-second poll.
GET /api/v1/user/queue/ws
Section titled “GET /api/v1/user/queue/ws”WebSocket upgrade. Pass ?ticket=<ticket>; the ticket is invalidated on use.
A session cookie is also accepted, since it is not in the URL. A bearer token
in the query string is not accepted and the parameter is not read.
Billing endpoints (Bearer auth)
Section titled “Billing endpoints (Bearer auth)”GET /api/v1/billing/me
Section titled “GET /api/v1/billing/me”Current plan, payment state, and usage.
{ "tier": "team", "entitled_tier": "free", "status": "past_due", "payment_action_required": true, "connections": 2, "repos": 7, "limits": { "included": { "connections": 5, "repos": 50 }, "ceiling": { "connections": null, "repos": 50 } }}| Field | Type | Description |
|---|---|---|
tier |
string | The plan the customer bought |
entitled_tier |
string | The plan the customer currently gets. Equals tier while the subscription is active or trialing; free otherwise |
status |
string | active, trialing, past_due, incomplete, paused, canceled, or unknown |
payment_action_required |
boolean | True on a paid plan whose status does not entitle it. The dashboard shows a banner and a portal link |
connections |
number | Connections in use |
repos |
number | Managed repositories in use |
limits.included |
object | {connections, repos} covered by the flat price; null means unlimited |
limits.ceiling |
object | {connections, repos} past which enrollment is refused with 402; null means unlimited |
While a subscription is not entitling, repositories beyond the Free
allowance are held rather than processed. GET /api/v1/user/repos reports this
per repository as hold_reason: "payment_required", and
POST /api/v1/user/repos/:id/reconcile returns 402 payment_required with a
portal link. The oldest repositories up to the Free allowance keep running.
Enforcement follows entitled_tier, so a past_due Team account is held to
Free caps. There is no grace period. See
docs/billing-design.md in the repository — Entitlement: tier AND status.
503 billing_unavailable — the plan could not be read. This is retryable
and explicitly not the same as being on the free tier: a transient database
error must never present a paying customer with free-tier limits.
POST /api/v1/billing/checkout
Section titled “POST /api/v1/billing/checkout”Creates a Stripe Checkout session for pro or team.
| Status | Meaning |
|---|---|
200 |
{ "checkout_url": "https://checkout.stripe.com/…" } |
400 invalid_tier |
tier must be pro or team |
409 subscription_exists |
The organization already has an entitling subscription. Use POST /api/v1/billing/change-plan to switch tiers; a second checkout would create a second subscription and charge twice |
503 stripe_not_configured |
Billing is not configured for this deployment. No Stripe call is made |
502 stripe_error |
Stripe rejected or could not be reached |
POST /api/v1/billing/change-plan
Section titled “POST /api/v1/billing/change-plan”Swaps tier (pro or team) on the organization’s existing Stripe
subscription — the in-product path subscription_exists above points to,
rather than the billing portal. Every subscription item is matched to its kind
by current price id and moved to that kind’s price on the target tier, with
proration_behavior: create_prorations. billing.tier/organizations.tier
are patched directly on success; the customer.subscription.updated webhook
re-applies the same tier from subscription_data.metadata.tier and is
idempotent, so this is a latency improvement, not a second source of truth.
| Status | Meaning |
|---|---|
200 |
{ "tier": "pro" | "team" } |
400 invalid_tier |
tier must be pro or team |
400 already_on_tier |
The organization is already on the requested tier |
409 no_active_subscription |
No entitling subscription to change. checkout links to POST /api/v1/billing/checkout |
409 subscription_state_mismatch |
The live subscription’s items don’t match the expected per-tier price set (e.g. a plan configured out of band). Use the billing portal instead |
503 stripe_not_configured |
Billing is not configured for this deployment |
502 stripe_error |
Stripe rejected the subscription update (e.g. a declined card on the prorated charge) |
POST /api/v1/billing/portal
Section titled “POST /api/v1/billing/portal”Creates a Stripe Customer Portal session: { "portal_url": "…" }. Same 503/502
semantics as checkout. Still the path for payment-method updates and
cancellation; plan changes go through change-plan above.
Account endpoints (Bearer auth)
Section titled “Account endpoints (Bearer auth)”GET /api/v1/me
Section titled “GET /api/v1/me”The authenticated user’s id: { "id": "<supabase-user-uuid>" }.
| Status | Meaning |
|---|---|
401 not_authenticated |
No valid session present |
GET /api/v1/auth/session
Section titled “GET /api/v1/auth/session”Whether the request carries a valid session: { "authenticated": true|false }.
The token is verified against Supabase — a cookie with the right name and a
bogus value reports false.
POST /api/v1/logout
Section titled “POST /api/v1/logout”Clears the session cookie. 204, no body.
GET /api/v1/tenant/export
Section titled “GET /api/v1/tenant/export”Everything Gondolier holds about your tenant, as JSON. A normal authenticated call — no request, no queue, no waiting.
{ "tenant": { "…": "…" }, "forge_connections": [ { "…": "…" } ], "managed_repos": [ { "…": "…" } ], "reconcile_status": [ { "…": "…" } ], "reconcile_status_truncated": false, "audit_log": [ { "…": "…" } ], "audit_log_truncated": false, "organization": { "…": "…" }, "billing": [ { "…": "…" } ]}reconcile_status stops at 1000 rows. audit_log stops at 500 rows. The
matching *_truncated field is true when more rows exist. Read the flag
before you treat either list as complete.
Credentials are redacted, not omitted. encrypted_token, token_nonce,
encrypted_dek, webhook_secret, and api_key_hash appear as
"[redacted]" — so the export shows you what exists without handing back
material that would let anyone act as your bot.
Nothing in this response can be used to authenticate anywhere.
Your source code is not included, because Gondolier never has it: the queue works entirely through your forge’s API and never clones a repository.
POST /api/v1/tenant/offboard
Section titled “POST /api/v1/tenant/offboard”Deletes the tenant and everything under it.
{ "confirm": "<your tenant id>" }The confirm value must equal your tenant id exactly, or the call fails with
400 confirm_mismatch. There is no undo and no soft-delete window — take an
export first if you want one.
Deletes, in dependency order: reconcile status, managed repositories, forge connections (and their stored credentials), audit log, and — if the tenant owns one — its billing row and organization.
What is deliberately left alone: anything Gondolier configured on your forge. Webhooks and branch protection stay until you remove them there. Silently reopening a protected base branch during an offboarding would be a worse surprise than a rule you can see and delete.
GET /api/v1/user/queue/activity
Section titled “GET /api/v1/user/queue/activity”The dashboard’s activity feed: per-repository queue state, the current batch,
the bisection tree from queue_batches,
recent events, and 7-day counters.
{ "repos": [ { "repo_slug": "acme/app", "instance_url": "https://forge.example", "base_branch": "main", "state": "staging", "ci_status": "passing", "ci_url": "https://github.com/…/runs/123", "staging_branch": "gondolier/main-1755…-1", "batch": [ { "pr": 7 } ], "last_reconcile": "2026-09-12T08:30:34Z", "last_reconcile_ok": true, "last_reconcile_error": null, "lease_state": "idle" } ], "recent_activity": [ { "timestamp": "…", "event": "pr_landed", "pr": 7, "repo_slug": "acme/app", "title": "…" } ], "recent_activity_truncated": false, "queue_batches": [ { "id": "…", "run_id": "1755…-1", "node_id": "r", "parent_node_id": null, "attempt": 1, "staging_sha": "6ba22b…", "gate_verdict": "success", "landed_prs": [7], "bounced_prs": null, "actions": [ { "pr": 7, "action": "landed", "staging_sha": "6ba22b…", "gate_verdict": "success", "cursor": "1755…-1|landed|7" } ], "parent_id": null, "repo_slug": "acme/app", "instance_url": "https://forge.example", "staging_branch": "gondolier/main-1755…-1", "prs": [7], "outcome": "success", "phase": null, "created_at": "…", "resolved_at": "…", "gate_source": null, "historical_terminals": [ { "pr": 7, "event": "landed" } ] } ], "queue_batches_truncated": false, "stats": { "today_merges": 3, "week_merges": 9, "week_bounces": 1, "merge_success_rate": 90, "week_batches": 14, "week_superseded": 4, "restage_rate": 29 }}The durable lineage fields follow the queue-invariant contract
(docs/designs/queue-invariant-contract.md):
run_id names the root execution, node_id/parent_node_id the logical
bisection node and its parent within the same run, attempt the staging/gate
attempt, and staging_sha/gate_verdict the exact evidence a PR mark
requires. landed_prs/bounced_prs/actions carry per-member finalization
accounting; a batch resolves terminal only when every stored member has a
durable action. Rows predating the contract have NULL identity and are
surfaced to the dashboard as record errors, never as tree nodes.
merge_success_rate measures terminal PR outcomes only (landed vs bounced),
not overall queue health: a landed PR can be re-tested several times first.
week_batches / week_superseded / restage_rate report that repeated work
over the same 7-day window, read from queue_batches.
The recent_activity array contains at most 20 newest events. The
recent_activity_truncated flag is true when more events exist.
The stats count fields, merge_success_rate, and restage_rate are null when the
underlying exact count cannot be read. Clients must display Unknown, not
zero, for these values.
Tenant-scoped: it returns only your repositories.
This endpoint was updated by RFC-0032: the old
queue_historyfield (staging-branch history reconstructed from audit logs) is no longer returned. The queue bisection tree is now a direct read from thequeue_batchestable.
The queue_batches array
Section titled “The queue_batches array”Each element represents a bisection-tree node produced by a single
reconcile run. The array is a flat list; the parent_id foreign key
encodes the tree structure (roots have parent_id: null).
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Batch identifier |
parent_id |
string (UUID) | null | Parent batch UUID; null for roots |
repo_slug |
string | null | Repository slug (owner/repo); resolved from the repo lookup |
instance_url |
string | null | Forge instance URL; resolved from the repo’s connection |
staging_branch |
string | Branch created for this batch’s merge attempt |
prs |
integer[] | PR numbers included in this batch |
outcome |
string | Node outcome (see below) |
phase |
string | null | Sub-state while outcome is running: waiting_gate or waiting_merge; null once resolved |
created_at |
string | ISO timestamp of batch creation |
resolved_at |
string | null | ISO timestamp when the node reached a terminal outcome; null while running |
gate_source |
string | null | Source of the gate decision for cached nodes. When the engine reused the parent batch’s CI evidence for one half of a bisection split, this is "cached"; the staging branch does not exist on the forge. null for all other nodes |
terminal_reason |
string | null | Safe cause for an operational_error row. Legacy rows may be null; the dashboard must keep the generic label in that case |
historical_terminals |
object[] | Evidence of PRs that reached a terminal event after this batch was created and belonging to the same repository. Each entry is { "pr": <int>, "event": "landed" | "bounced" }. An empty array means no terminal evidence is attached — missing evidence must be read as unknown, never as resolved. |
The array holds at most 200 rows, newest first. queue_batches_truncated is true when more rows exist.
outcome values:
| Value | Description |
|---|---|
running |
Live batch (use phase to distinguish gate-wait vs merge-wait) |
success |
PR(s) landed |
bounced |
PR(s) were removed and rejected |
bisected |
Batch failed and was split into child batches |
error |
Non-boolean outcome (cancelled / infra / missing gate) |
held_success |
Gate passed; outcome deferred until the root node resolves |
held_failure |
Gate failed; outcome deferred until the root node resolves |
superseded |
A speculative fanout node was replaced by a re-stage on the resolved baseline |
invalidated |
The node’s root was torn down (base advanced / candidate changed) |
operational_error |
No verified engine outcome. terminal_reason gives the safe cause when known |
The above list comes from the CHECK constraint in migrations
021and022and from direct writes inindex.mjs. If you see an outcome not listed here, the database allows it and the dashboard should treat it as unknown.
Service endpoints (no auth)
Section titled “Service endpoints (no auth)”GET /api/healthz
Section titled “GET /api/healthz”The single public health contract.
200 {"ok": true} when the Worker can reach Supabase; 503 {"ok": false, "dependency": "supabase"} when it cannot.
It measures Worker-to-Supabase reachability and nothing else. It does not report queue progress, cron liveness, forge reachability, container health, or credential validity.
GET /readyz (retired)
Section titled “GET /readyz (retired)”The exact https://api.gondolier.dev/readyz route returns 410 Gone. It does
not check dependencies, read credentials, or replace /api/healthz. The
similarly named /api/v1/healthz route remains private and returns 401 without
authentication.
GET /api/container-health
Section titled “GET /api/container-health”A public, state-only engine signal. The Worker selects one managed repository and asks its LeaseDO to reach the singleton container health endpoint.
200 {"ok": true} means the engine container answered. 503 {"ok": false, "dependency": "container"} means the Worker could not verify the container.
The response contains no tenant data or credentials. This endpoint can wake the
container. It does not change the meaning of /api/healthz.
GET /api/queue-health
Section titled “GET /api/queue-health”Public queue progress. No auth. Returns states only, never counts. No tenant identity. No repo identity.
The supersede_rate signal trips when the 24 hour supersede rate from queue_batches passes 30 percent. Basis: the fleet measured 28 percent over 14 days on 2026-09-14, with weekly rates of 0, 18, 32, and 33 percent. The degraded_by field names the tripped signal: none, tick_freshness, overdue_repo, supersede_rate, or phase_age. An unreadable batch history reads unknown. It never reads healthy.
POST /api/v1/stripe/webhook
Section titled “POST /api/v1/stripe/webhook”Stripe subscription lifecycle. Verified by Stripe-Signature (HMAC-SHA256 over
the raw body, five-minute replay window); an unverifiable request gets 401 and
is not parsed. Not for tenant use.
GET /api/v1/posthog-config
Section titled “GET /api/v1/posthog-config”Returns a JavaScript snippet setting window.__POSTHOG_API_KEY__, so the
analytics key is deploy-time configuration rather than a committed constant.
Empty when telemetry is unconfigured.
Admin endpoints
Section titled “Admin endpoints”Admin endpoints require an admin-role Supabase session (Authorization: Bearer)
or the legacy X-Admin-Key fallback. They bypass tenant scoping.
GET /api/v1/admin/repos
Section titled “GET /api/v1/admin/repos”List all repositories across all tenants. Supports search, pagination, and returns the full view with lease state and reconcile data.
GET /api/v1/admin/repos?q=owner/repo&limit=50&offset=0Authorization: Bearer <admin-supabase-token>| Parameter | Type | Description |
|---|---|---|
q |
string | Search filter on repo name (ILIKE, max 200 chars) |
limit |
int | Page size, capped at 100 |
offset |
int | Pagination offset |
Response (200):
{ "repos": [ { "repo": "owner/repo", "base_branch": "main", "status_context": "gondolier", "merge_style": "squash", "automerge": false, "instance_url": "https://git.example.com", "tenant_id": "550e8400-...", "owner": "550e8400-...", "connection": { "bot_username": "gondolier", "instance_url": "https://git.example.com", "token_type": "pat" }, "wakeup_mode": "poll", "provisioning": { "webhook": { "status": "configured" }, "protection": { "status": "not_requested" }, "ci_gate": { "status": "missing" }, "last_provisioned_at": "2026-08-04T12:00:00Z" }, "created_at": "2026-08-04T12:00:00Z", "updated_at": "2026-08-04T13:00:00Z", "reconcile": { "ran_at": "2026-01-01T00:00:00Z", "ok": true, "error": null, "error_code": null }, "lease_state": "idle" } ], "limit": 50, "offset": 0, "total": 142}GET /api/v1/admin/connections
Section titled “GET /api/v1/admin/connections”List all connections across all tenants. Token fields are excluded.
GET /api/v1/admin/connectionsAuthorization: Bearer <admin-supabase-token>Response (200):
{ "connections": [ { "id": "660e8400-...", "tenant_id": "550e8400-...", "instance_url": "https://git.example.com", "bot_username": "gondolier", "token_type": "pat", "created_at": "2026-08-04T12:00:00Z" } ], "connections_total": 1, "connections_truncated": false}The list stops at 200 rows. connections_total is the number of rows in this
response. connections_truncated is true when more connections exist. Read
the flag before you treat the list as complete.
GET /api/v1/admin/stats
Section titled “GET /api/v1/admin/stats”Actionable operational counts for the admin console overview.
GET /api/v1/admin/statsAuthorization: Bearer <admin-supabase-token>Response (200):
{ "repos_total": 42, "repos_truncated": false, "tenants_total": 3, "connections_total": 5, "connections_truncated": false, "credential_formats": { "v3": 4, "v2": 1, "v1": 0, "unrecognised": 0 }, "credential_keks": { "1": 1, "2": 3 }, "active_kek": "2", "needs_admin": 1, "reconcile_failures": 0, "per_tenant": { "550e8400-...": 25, "550e8401-...": 17 }, "last_reconciles": [ { "ok": true, "ran_at": "2026-01-01T00:00:00Z" } ], "last_reconciles_truncated": false}| Field | Type | Description |
|---|---|---|
repos_total |
int | Managed repositories across all tenants, capped at 1000. Read repos_truncated before you treat the value as exact |
repos_truncated |
bool | true when more than 1000 managed repositories exist |
tenants_total |
int | Total tenants |
connections_total |
int | Total forge connections, capped at 1000. Read connections_truncated before you treat the value as exact |
connections_truncated |
bool | true when more than 1000 forge connections exist |
credential_formats |
object | Count of stored credentials per encryption format. Ciphertext is read only to classify it and never leaves the handler |
credential_keks |
object | Count of enc:v3: records per KEK id. A rotation is complete when only the active KEK appears |
active_kek |
string | null | KEK id new writes use; null when CREDENTIAL_KEK_ID is unset, which keeps v3 inert |
needs_admin |
int | Repositories whose webhook or branch-protection provisioning needs forge admin action |
reconcile_failures |
int | Exact count of managed repositories whose latest recorded reconcile failed |
per_tenant |
object | Per-tenant repo count map (tenant_id → count) |
last_reconciles |
array | Most recent reconcile outcomes (last 20) |
last_reconciles_truncated |
bool | true when more than 20 reconcile outcomes exist |
The managed repository read stops at 1000 rows. repos_total, per_tenant,
and needs_admin count only the rows in that page. Read repos_truncated
before you treat them as complete.
GET /api/v1/admin/metrics/trends
Section titled “GET /api/v1/admin/metrics/trends”Daily batch buckets from queue_batches. Needs admin auth. Defaults to 30 days. Caps at 90. Aggregates on read with one bounded read per call. Each day reports batches, success, superseded, bisected, bounced, supersede_pct, p50_minutes, p90_minutes, and success_latency_minutes. The response names window_days_requested and window_days_covered, so a short history cannot read as a long one. It carries a sources record with row count and newest_at per source. An unreachable history returns 503.
POST /api/v1/admin/credentials/rewrap
Section titled “POST /api/v1/admin/credentials/rewrap”Re-encrypts stored forge credentials at the current best format — the pass
credential-key-rotation.md step 3
calls for. Runs inside the Worker so plaintext stays in the trust boundary that
already handles it.
POST /api/v1/admin/credentials/rewrapX-Admin-Key: <admin-key>Content-Type: application/json
{ "apply": true, "limit": 200 }| Field | Type | Default | Description |
|---|---|---|---|
apply |
bool | false |
Without it this is a dry run — it reports what would change and writes nothing |
limit |
int | 100 | Records per call, capped at 200. Repeat until rewrapped is 0 |
Response (200):
{ "scanned": 41, "already_current": 4, "rewrapped": 37, "failed": 0, "skipped_empty": 0, "applied": true, "active_kek": "2"}| Field | Type | Description |
|---|---|---|
scanned |
int | Records examined |
already_current |
int | Already enc:v3: under the active KEK; skipped |
rewrapped |
int | Re-encrypted (or, in a dry run, that would be) |
failed |
int | Could not be decrypted, verified, or written. Left untouched |
skipped_empty |
int | Rows with no stored credential |
Each record is re-encrypted, then decrypted again and compared, before the row is written — a ciphertext that would not decrypt back never reaches the database. Re-running is safe. No plaintext or ciphertext appears in the response or in logs; failures are logged by connection id only.
Returns 500 not_configured when CREDENTIAL_MASTER_KEY is absent.
Webhook endpoint
Section titled “Webhook endpoint”POST /api/v1/webhooks/forgejo
Section titled “POST /api/v1/webhooks/forgejo”Receives webhook events from Forgejo/Gitea/Codeberg. No API key required — security is enforced via HMAC-SHA256 signature verification. Duplicate delivery IDs are recorded only after a valid signature is verified.
POST /api/v1/webhooks/forgejoX-Hub-Signature-256: sha256=<hex_digest>Content-Type: application/jsonSupported events: push, pull_request.
Response codes
Section titled “Response codes”| Code | Meaning |
|---|---|
| 200 | Event processed successfully |
| 400 | Bad request body or missing fields |
| 401 | HMAC signature verification failed |
| 404 | Repo not managed by gondolier |
| 409 | Lease held by another worker (event dropped) |
| 500 | Internal server error |
A 409 means the lease is currently being processed. The sender should retry with exponential backoff.
Token security
Section titled “Token security”- Tenant forge tokens are encrypted at rest with AES-GCM (
enc:v1:). - The encrypted token is never returned by any API response.
- The encrypted token is never logged in plaintext.
- The token is decrypted only in-memory for the duration of a single operation.
- Admin endpoints never return token fields.