Skip to content

API Reference

Status: Authoritative API reference.

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 required
X-Admin-Key: <admin-key> # legacy fallback

Removed: the X-API-Key header 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.

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).

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.


These endpoints require a valid Supabase session token.

List the authenticated user’s forge connections. Token fields are never returned.

GET /api/v1/connections
Authorization: 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"
}
]

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/connections
Authorization: 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"
}
}

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/:id
Authorization: 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"
}

Remove a forge connection. All repos managed under this connection are cascade-deleted.

DELETE /api/v1/connections/:id
Authorization: Bearer <token>

Response (204): empty body

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/discover
Authorization: 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

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/repos
Authorization: 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)

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/repos
Authorization: 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.

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.

Tune a managed repository. Every field is optional; send only what changes. Returns the full safe view.

PATCH /api/v1/user/repos/:id
Authorization: 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 webhook runs provisioning automatically to register the webhook. Changing status_context on 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).

Remove a managed repository.

DELETE /api/v1/user/repos/:id
Authorization: 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/template
Authorization: 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-pr
Authorization: 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/verify
Authorization: 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.

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

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/provision
Authorization: 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

Trigger a manual reconcile for a repository. Runs the merge queue algorithm immediately and persists the outcome.

POST /api/v1/user/repos/:id/reconcile
Authorization: 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"
}
}

Per-repo queue health snapshot. Returns the complete safe view for each repo plus live lease state.

GET /api/v1/user/queue/status
Authorization: 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

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.

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.


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.

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

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)

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.


The authenticated user’s id: { "id": "<supabase-user-uuid>" }.

Status Meaning
401 not_authenticated No valid session present

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.

Clears the session cookie. 204, no body.

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.

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.

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_history field (staging-branch history reconstructed from audit logs) is no longer returned. The queue bisection tree is now a direct read from the queue_batches table.


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 021 and 022 and from direct writes in index.mjs. If you see an outcome not listed here, the database allows it and the dashboard should treat it as unknown.


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.

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.

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.

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.

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.

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 require an admin-role Supabase session (Authorization: Bearer) or the legacy X-Admin-Key fallback. They bypass tenant scoping.

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=0
Authorization: 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
}

List all connections across all tenants. Token fields are excluded.

GET /api/v1/admin/connections
Authorization: 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.

Actionable operational counts for the admin console overview.

GET /api/v1/admin/stats
Authorization: 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.

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.

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/rewrap
X-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.


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/forgejo
X-Hub-Signature-256: sha256=<hex_digest>
Content-Type: application/json

Supported events: push, pull_request.

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.


  • 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.