Reference
API reference
The programmatic surface for a Descant tenant. This document is the CONTRACTED subset, not the whole product: the dashboard can do things no operation here covers yet, and the set grows as operations are specified. An absent capability means it is not contracted, never that it is forbidden.
Contract version 3.0.0. Every request goes to https://app.descant.run. The machine-readable document this page is generated from is openapi.json (OpenAPI 3.1). A shell client for the same operations is on the CLI page.
Authentication
Send the credential as a bearer token: Authorization: Bearer …. The schemes this API accepts:
apiKey— A tenant API key, created in the dashboard and shown once. The tenant is taken from the key, never from the request. Each operation's `security` entry names the scope (`<family>:<read|write>`) the key must carry; a key without it is refused 403 before the operation runs.
GET /api/v1/runs
Operation customer.runs.list. List this key's tenant's runs, newest first.
Read-only. The key's tenant is taken from the matched key row and never from the request, so a key cannot read another tenant's runs. Rate limited per source before authentication, so an unauthenticated caller cannot probe cheaply. Ownership-scoped (#3307): runs of a repository the tenant has since removed are still returned, because `customer.repos.list` still shows that repository and eliding its runs would lose audit history. Paged by an opaque page token — a run history is unbounded, and this list ALREADY stopped at fifty rows, silently, with no way to ask for the fifty-first; follow `nextPageToken` until it is `null`. The rows arrive as `items`, not `runs`: the array was never going to stay complete, and a renamed field is a break a caller can see rather than a truncation it cannot. `billing` is a tenant fact read once per request and rides every page unchanged. `repositoryId`, `state` and the `createdAfter`/`createdBefore` window each narrow the page and are each optional; absent means unfiltered. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`.
Security: apiKey (runs:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
repositoryId | query | string | no | Only this repository's runs. The id `customer.repos.list` returns, which is the only programmatic source of one. A repository belonging to another tenant matches nothing rather than erroring — the ownership join answers before the filter does. |
state | query | array of "idle" | "picking" | "planning" | "implementing" | "opening_pr" | "under_review" | "resolving" | "merging" | "capturing_learnings" | "reviewing_learnings" | "resolving_learnings" | "merging_learnings" | "filing_residuals" | "grooming_issues" | "refreshing_wiki" | "verifying" | "positioning_pr" | "positioning_resolve" | "self_reviewing" | "fixing_ci" | "decomposing" | "filing_children" | "researching" | "requirements_by_lens" | "tests_by_lens" | "red_first_check" | "simplifying" | "pre_filter" | "starting_checks" | "reviewing_decomposition" | "reviewing_plan" | "reviewing_build" | "reviewing_code" | "stamping" | "follow_up_round" | "addressing_review" | "updating_branch" | "sizing" | "arbitrating" | "refreshing_docs" | "done" | "failed" | "cancelled" | no | Repeatable. The run's CURRENT state, not its outcome — `failed` and `cancelled` are terminal, `under_review` is not. Absent or empty means every state. |
createdAfter | query | string | no | Runs created at or after this instant, INCLUSIVE. Filters the column the list orders by. |
createdBefore | query | string | no | Runs created at or before this instant, INCLUSIVE. Filters the column the list orders by. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].access | "content" | yes | The key's creator may read this run's repository, so the row is whole (#27590). |
items[].id | string | yes | |
items[].issue | string | yes | The issue's external identifier, as the provider names it. |
items[].issueTitle | string | null | yes | Snapshotted on the run (#3262). `null` on legacy runs and on seed paths that could not fetch it. |
items[].state | string | yes | The pipeline state. Open by design: states are added as the pipeline grows. COARSE: `done` includes a run held for a person that opened no pull request, and `failed` includes a run you stopped by closing its issue. Read `endingClass` for how the run ended. |
items[].endingClass | "done" | "predictable" | "outage_park" | "unexpected" | "unknown" | null | yes | How the run ended. `done`: an expected, complete ending. That is either delivered work (a merged pull request, a split into smaller issues, or a pull request left open for you to merge because auto-merge is off) or a named reason there was nothing to deliver (the repository already does what the issue asks, the issue waits on another issue first, or an earlier run already split it). `done` does not by itself mean a pull request or a split was produced: read `endingCause`, and `prNumber` where the response carries it, to tell delivered work from a nothing-to-deliver ending. `predictable`: it stopped on a named cause you can see, such as the issue being closed, a setting of your repository, or a hold that waits for a person. `outage_park`: a service the run depends on was down and the run waited. `unexpected`: an ending the list of expected endings does not name; our on-call team is alerted to it. `unknown`: this read cannot name how the run ended. For a run that ended in the last few minutes, that is usually a new kind of ending this read does not know yet, and reading the run again later names it; for a run that ended before we recorded how runs end, it can stay `unknown`. Read `state` and `endingCause` for what is known. `null` while the run has not ended. Key on this field, not on `state`, to tell an expected, complete ending from a run held for a person or stopped on a named cause. |
items[].endingCause | string | null | yes | The cause the run ended on, for every ending, as a key such as `code:issue_closed_upstream` or `hold:autoMergeDisabled`. For an expected ending it is a key from the list of expected endings; for an `unexpected` one it is the key of the cause we recorded, which our on-call team is alerted to. Open by design, as `state` is: keys are added as endings are named, so match the keys you handle and read any other by its `endingClass`. `null` while the run has not ended, and for the rare ending that recorded no readable cause. |
items[].priority | string | null | yes | |
items[].prNumber | integer | null | yes | |
items[].provider | string | yes | |
items[].repoOwner | string | yes | |
items[].repoName | string | yes | |
items[].startedAt | string | yes | |
items[].finishedAt | string | null | yes | |
items[].dispatchBlockedReason | "slot" | "vendor_budget" | null | yes | `slot`: the run's account is at its concurrency cap (#1995). `vendor_budget`: the fleet vendor budget, or this tenant's fair share of it, is exhausted (#7553), and only ever present when the operator has armed that budget. `null` on a run that is not parked. |
items[].dispatchWaiting | "paused-no-credits" | "paused-cap" | "paused-operator-hold" | "paused-on-park" | "waiting-for-slot" | "waiting-for-vendor-budget" | null | yes | The dashboard's own reading of why this run rests. `null` when it is not waiting on any of these. |
items[].attempts | integer | yes | How many runs of this run's issue (the same issue in the same repository) this request's filter reaches: its repository, states and created window, across every page, not only this one. Every row of one issue carries the same value, so a caller paging through an issue's runs reads one count throughout. A run with no issue identifier shares an issue with no other run and reads `1`. Content rows only (#14919). |
items[].access | "summary" | yes | The key's creator may not read this run's repository, so its content is left out (#27590). |
items[].id | string | yes | |
items[].state | string | yes | The pipeline state. Open by design: states are added as the pipeline grows. COARSE: `done` includes a run held for a person that opened no pull request, and `failed` includes a run you stopped by closing its issue. Read `endingClass` for how the run ended. |
items[].endingClass | "done" | "predictable" | "outage_park" | "unexpected" | "unknown" | null | yes | How the run ended. `done`: an expected, complete ending. That is either delivered work (a merged pull request, a split into smaller issues, or a pull request left open for you to merge because auto-merge is off) or a named reason there was nothing to deliver (the repository already does what the issue asks, the issue waits on another issue first, or an earlier run already split it). `done` does not by itself mean a pull request or a split was produced: read `endingCause`, and `prNumber` where the response carries it, to tell delivered work from a nothing-to-deliver ending. `predictable`: it stopped on a named cause you can see, such as the issue being closed, a setting of your repository, or a hold that waits for a person. `outage_park`: a service the run depends on was down and the run waited. `unexpected`: an ending the list of expected endings does not name; our on-call team is alerted to it. `unknown`: this read cannot name how the run ended. For a run that ended in the last few minutes, that is usually a new kind of ending this read does not know yet, and reading the run again later names it; for a run that ended before we recorded how runs end, it can stay `unknown`. Read `state` and `endingCause` for what is known. `null` while the run has not ended. Key on this field, not on `state`, to tell an expected, complete ending from a run held for a person or stopped on a named cause. |
items[].endingCause | string | null | yes | The cause the run ended on, for every ending, as a key such as `code:issue_closed_upstream` or `hold:autoMergeDisabled`. For an expected ending it is a key from the list of expected endings; for an `unexpected` one it is the key of the cause we recorded, which our on-call team is alerted to. Open by design, as `state` is: keys are added as endings are named, so match the keys you handle and read any other by its `endingClass`. `null` while the run has not ended, and for the rare ending that recorded no readable cause. |
items[].priority | string | null | yes | |
items[].provider | string | yes | |
items[].startedAt | string | yes | |
items[].finishedAt | string | null | yes | |
items[].dispatchBlockedReason | "slot" | "vendor_budget" | null | yes | `slot`: the run's account is at its concurrency cap (#1995). `vendor_budget`: the fleet vendor budget, or this tenant's fair share of it, is exhausted (#7553), and only ever present when the operator has armed that budget. `null` on a run that is not parked. |
items[].dispatchWaiting | "paused-no-credits" | "paused-cap" | "paused-operator-hold" | "paused-on-park" | "waiting-for-slot" | "waiting-for-vendor-budget" | null | yes | The dashboard's own reading of why this run rests. `null` when it is not waiting on any of these. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
billing | object | null | yes | |
billing.pauseReason | "balance" | "cap" | "operator" | null | yes | The persisted pause reason, narrowed to the known set — an unrecognised future value reads as `null` rather than leaking. |
billing.outOfCredits | boolean | yes | Derived: the pause is a balance pause rather than an operator or cap pause. `false` with no billing signal. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_query — A query parameter failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
POST /api/v1/runs
Operation customer.runs.start. Start a run on one issue.
Starts a run on one issue of a repository this key's account has connected, through the same checks the dashboard's start form makes, and answers the new run's id once it is queued. The run is started on behalf of the account that created the key. Every refusal says whether waiting can help. `capacity_exhausted` (the repository or account is at its concurrent-run limit) and `upstream_unavailable` mean nothing started and a later attempt may succeed, after `retryAfterSeconds` when the answer carries it. `billing_paused` clears when an owner resumes billing. `conflict` and `not_found` answer the same until something about the repository or the issue changes. A refusal the service named carries `reason`, from a closed set a caller can branch on: `repo-not-found`, `repo-paused`, `repo-not-active`, `repo-offboarding`, `repo-bench-fixture`, `fleet-halted`, `manual-merge-pending`, `run-active`, `serialize-held`, `slot-capped`, `together-unit-unsupported`, `together-unit-unresolvable`, `issuegraph-block-unreadable`, `issue-content-unavailable`, `terminal-failed-suppressed`, `terminal-failed-pr-open`, `held-pr-merged`, `terminal-failed-pr-merged`, `explained-done-pr-open`, `explained-done-pr-merged`, `deliverable-not-code`, `issue-closed` or `issue-decomposed`. A repository another account owns and one that does not exist are one indistinguishable `not_found`. A start over the API is treated as unattended: an issue whose latest run failed, or whose earlier pull request is still open or has already merged, is refused rather than run and billed again. A person can still start such an issue from the dashboard. `outcome_unknown` means the run may have started: read the runs list before starting again, because a second start is billed as a second run. Send an `Idempotency-Key` to make a retry safe: the same key and body within 24 hours answers the first result instead of starting a second run. When the service is known not to have started a run, the key is given back and a retry under it runs: always after `capacity_exhausted`, and after `upstream_unavailable` or `internal_error` when the run service answered. A key whose request may have started a run stays spent: every retry under it answers `outcome_unknown` until it expires, so read the runs list before starting again under a new key.
Security: apiKey (runs:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
tenantRepoId | string | yes | The repository's id, as `customer.repos.list` returns it. A repository another account owns is `not_found`. |
issueNumber | integer | yes | The issue's number in that repository. |
Response
| field | type | required | description |
|---|---|---|---|
runId | string | yes | The run that was started. Read it with `customer.runs.get`. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / billing_paused — This tenant's runs are paused for billing; resume billing before retrying. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | capacity_exhausted — No capacity is available for this request yet. / upstream_unavailable — A service this operation depends on did not answer. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/runs/{runId}
Operation customer.runs.get. Read one run's timeline and findings, exactly as its own page shows them.
The run page's live snapshot under the key's own tenant: the five canonical phases as `deriveTimeline` derives them, their statuses and mapped copy, the server clocks a stall is measured against, the review findings and the follow-up counts. IT PUBLISHES WHAT THE PAGE RENDERS, through the same projection — so the two cannot tell a customer different stories about one run, and a test pins that they call the same function. The MAPPED failure copy crosses and the raw failure code does not cross as a field of its own; `endingClass` says whether the published list of expected endings names how the run ended, and `endingCause` names the cause for every ending. The derived findings view crosses and the raw review checkpoint does not. Measure every age against `serverNowMs` rather than the caller's clock. A missing run and another tenant's run are one indistinguishable `not_found`, so this is not an oracle for what somebody else owns.
Security: apiKey (runs:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
access | "content" | yes | The key's creator may read this run's repository, so the run is read whole (#27590). |
state | string | yes | The pipeline state. Open by design, as the list read's is. COARSE: `done` includes a run held for a person that opened no pull request, and `failed` includes a run you stopped by closing its issue. Read `endingClass` for how the run ended. |
endingClass | "done" | "predictable" | "outage_park" | "unexpected" | "unknown" | null | yes | How the run ended. `done`: an expected, complete ending. That is either delivered work (a merged pull request, a split into smaller issues, or a pull request left open for you to merge because auto-merge is off) or a named reason there was nothing to deliver (the repository already does what the issue asks, the issue waits on another issue first, or an earlier run already split it). `done` does not by itself mean a pull request or a split was produced: read `endingCause`, and `prNumber` where the response carries it, to tell delivered work from a nothing-to-deliver ending. `predictable`: it stopped on a named cause you can see, such as the issue being closed, a setting of your repository, or a hold that waits for a person. `outage_park`: a service the run depends on was down and the run waited. `unexpected`: an ending the list of expected endings does not name; our on-call team is alerted to it. `unknown`: this read cannot name how the run ended. For a run that ended in the last few minutes, that is usually a new kind of ending this read does not know yet, and reading the run again later names it; for a run that ended before we recorded how runs end, it can stay `unknown`. Read `state` and `endingCause` for what is known. `null` while the run has not ended. Key on this field, not on `state`, to tell an expected, complete ending from a run held for a person or stopped on a named cause. |
endingCause | string | null | yes | The cause the run ended on, for every ending, as a key such as `code:issue_closed_upstream` or `hold:autoMergeDisabled`. For an expected ending it is a key from the list of expected endings; for an `unexpected` one it is the key of the cause we recorded, which our on-call team is alerted to. Open by design, as `state` is: keys are added as endings are named, so match the keys you handle and read any other by its `endingClass`. `null` while the run has not ended, and for the rare ending that recorded no readable cause. |
steps | array of object | yes | The five canonical phases as `deriveTimeline` derives them — the SAME call the page makes. |
steps[].key | string | yes | |
steps[].label | string | yes | |
steps[].status | "completed" | "current" | "failed" | "held" | "pending" | "skipped" | "stalled" | "stopped" | "not-reached" | "warning" | yes | |
steps[].errorMessage | string | null | yes | The MAPPED customer-facing copy, never the raw failure code — the projection strips that before it reaches any surface. |
steps[].reviewIterations | integer | no | How many review passes actually RAN. Present on the review step whenever that count is at least one, so `1` is an ordinary reviewed run rather than an anomaly; absent when no pass ran, and absent on every other step. The count is not a raw tally of review states: the pre-review positioning step maps to the same phase and is not a pass, and a drift-reposition abort re-enters the review state without running the reviewer, so it is subtracted rather than counted. |
stepDetails | object | yes | Sub-steps keyed by step `key`, for the steps that have them. |
findings | object | yes | |
findings.kind | "list" | yes | |
findings.items | array of object | yes | |
findings.items[].id | string | yes | The stable finding id (`review-comment:<id>`). |
findings.items[].threadUrl | string | null | yes | Deep link to the PR review thread, or `null` where the coordinates make one underivable. |
findings.items[].resolution | "fixed" | "rebutted" | "outOfScope" | "resolving" | "pending" | yes | |
findings.items[].rationale | string | null | yes | The classifier's recorded prose about this tenant's own PR. `null` on a not-yet-classified item. FREE TEXT — see this family's note on `findings`. |
findings.kind | "clean" | yes | |
findings.kind | "resolvedSummary" | yes | |
findings.rounds | integer | yes | |
findings.kind | "outOfDiff" | yes | |
findings.findingsCount | integer | null | yes | |
findings.kind | "reviewUnresolvable" | yes | |
findings.findingsCount | integer | null | yes | |
findings.kind | "unrecorded" | yes | |
findings.findingsCount | integer | null | yes | |
findings.finished | true | no | |
findings.flagged | true | no | |
heldDisclosure | object | null | yes | |
heldDisclosure.posted | boolean | yes | Whether posting the notice to the pull request was CONFIRMED. `false` means unconfirmed, not absent: an attempt that timed out may still have landed. |
heldDisclosure.outcome | "named" | "incomplete" | "unreadable" | "withheld" | yes | What the notice could say: `named` lists the concerns; `incomplete` lists the ones an incomplete record can still name; `unreadable` and `withheld` name none. |
heldDisclosure.concerns | array of object | yes | The concerns the notice named, in its order, as plain text. FREE TEXT about this tenant's own change. |
heldDisclosure.concerns[].location | string | yes | |
heldDisclosure.concerns[].severity | string | yes | |
heldDisclosure.concerns[].summary | string | yes | |
heldDisclosure.concerns[].detail | string | yes | |
heldDisclosure.unnamedCount | integer | yes | Concerns the review holds that the notice did not name. |
serverNowMs | integer | yes | The server clock this response was built at. Anchor every age against THIS, not the caller's clock. |
stallAnchorMs | integer | yes | When the current customer-VISIBLE phase was entered — what the stall clock measures from. A multi-state visible phase deliberately does not reset it. |
stateEnteredAtMs | integer | yes | The INTERNAL state's entry, verbatim — not bumped on a resolve self-loop. |
resolveProgressAtMs | integer | null | yes | |
lastHeartbeatAtMs | integer | null | yes | |
finishedAtMs | integer | null | yes | |
currentStepNotStarted | boolean | yes | The run is at a phase that has produced no heartbeat, so it renders `stalled` rather than `current`. Derived ONCE server-side so every pane reads the same answer. |
parkNote | string | null | yes | Non-null only while the run is paused on a recorded wait: what it is waiting on, whether it resumes by itself, and whether waiting or cancelling is the better move. When set, it is the note the page shows on the current step. |
isTerminal | boolean | yes | |
awaitingManualMerge | boolean | yes | A done run whose deliberately-unmerged PR still awaits a human. |
skippedStepNote | string | yes | The note a `skipped` Merge step renders. |
mergeStepNote | string | null | yes | Non-null only where the merge took the host-backstop path because the Checks permission was never granted. |
heldStepNote | string | null | yes | Non-null only where the timeline actually carries a `held` Merge step. |
instructionLikeContentNotes | array of string | yes | Where the run met content that read as instructions to it, in the issue, a repository file or a comment thread: one plain sentence per place, saying whether it was not followed or only partly followed. It never repeats what the content said. The same sentences a pull request's description carries; for a run that opened none, this is where they are told. Empty when nothing was flagged. |
baseBranch | string | null | yes | |
drift | "merged_externally" | "closed_externally" | "needs_rebase" | null | yes | What happened to a finished run's open pull request after it was left for a person to merge: `merged_externally` (someone merged it), `closed_externally` (someone closed it without merging) or `needs_rebase` (the base branch moved and it now conflicts). `null` on every other run, including one still in progress or one that failed. The run page shows the same panel. |
chargesReversed | boolean | yes | `true` when this run's charges were reversed because it stopped due to a problem on our side, the note the run page shows on a failed run. Only for the run's latest attempt, and never for a run whose change had already merged before a later step failed. Carries no amount; read the billing ledger for that. |
runKind | string | yes | |
seededFromRunId | string | null | yes | The run this one was seeded from, where it was. |
deferredFollowUpCount | integer | yes | |
discretionFollowUpCount | integer | yes | |
secondOrderFollowUpCount | integer | yes | |
diminishingReturnsFollowUpCount | integer | yes | |
unresolvedFollowUpCount | integer | yes | |
adjudicatedSetAsideCount | integer | yes | |
deferredOutOfDiffCount | integer | yes | |
unanchoredCount | integer | yes | |
toolingProposalCount | integer | yes | |
quarantinedCount | integer | yes | |
worthinessDemotedCount | integer | yes | |
selfReviewResidualCount | integer | yes | |
selfReviewResidualsWithAttempts | integer | yes | |
access | "summary" | yes | The key's creator may not read this run's repository, so its content is left out (#27590). |
state | string | yes | The pipeline state. Open by design, as the list read's is. COARSE: `done` includes a run held for a person that opened no pull request, and `failed` includes a run you stopped by closing its issue. Read `endingClass` for how the run ended. |
endingClass | "done" | "predictable" | "outage_park" | "unexpected" | "unknown" | null | yes | How the run ended. `done`: an expected, complete ending. That is either delivered work (a merged pull request, a split into smaller issues, or a pull request left open for you to merge because auto-merge is off) or a named reason there was nothing to deliver (the repository already does what the issue asks, the issue waits on another issue first, or an earlier run already split it). `done` does not by itself mean a pull request or a split was produced: read `endingCause`, and `prNumber` where the response carries it, to tell delivered work from a nothing-to-deliver ending. `predictable`: it stopped on a named cause you can see, such as the issue being closed, a setting of your repository, or a hold that waits for a person. `outage_park`: a service the run depends on was down and the run waited. `unexpected`: an ending the list of expected endings does not name; our on-call team is alerted to it. `unknown`: this read cannot name how the run ended. For a run that ended in the last few minutes, that is usually a new kind of ending this read does not know yet, and reading the run again later names it; for a run that ended before we recorded how runs end, it can stay `unknown`. Read `state` and `endingCause` for what is known. `null` while the run has not ended. Key on this field, not on `state`, to tell an expected, complete ending from a run held for a person or stopped on a named cause. |
endingCause | string | null | yes | The cause the run ended on, for every ending, as a key such as `code:issue_closed_upstream` or `hold:autoMergeDisabled`. For an expected ending it is a key from the list of expected endings; for an `unexpected` one it is the key of the cause we recorded, which our on-call team is alerted to. Open by design, as `state` is: keys are added as endings are named, so match the keys you handle and read any other by its `endingClass`. `null` while the run has not ended, and for the rare ending that recorded no readable cause. |
steps | array of object | yes | The five canonical phases as `deriveTimeline` derives them — the SAME call the page makes. |
steps[].key | string | yes | |
steps[].label | string | yes | |
steps[].status | "completed" | "current" | "failed" | "held" | "pending" | "skipped" | "stalled" | "stopped" | "not-reached" | "warning" | yes | |
steps[].errorMessage | string | null | yes | The MAPPED customer-facing copy, never the raw failure code — the projection strips that before it reaches any surface. |
steps[].reviewIterations | integer | no | How many review passes actually RAN. Present on the review step whenever that count is at least one, so `1` is an ordinary reviewed run rather than an anomaly; absent when no pass ran, and absent on every other step. The count is not a raw tally of review states: the pre-review positioning step maps to the same phase and is not a pass, and a drift-reposition abort re-enters the review state without running the reviewer, so it is subtracted rather than counted. |
stepDetails | object | yes | Sub-steps keyed by step `key`, for the steps that have them. |
serverNowMs | integer | yes | The server clock this response was built at. Anchor every age against THIS, not the caller's clock. |
stallAnchorMs | integer | yes | When the current customer-VISIBLE phase was entered — what the stall clock measures from. A multi-state visible phase deliberately does not reset it. |
stateEnteredAtMs | integer | yes | The INTERNAL state's entry, verbatim — not bumped on a resolve self-loop. |
resolveProgressAtMs | integer | null | yes | |
lastHeartbeatAtMs | integer | null | yes | |
finishedAtMs | integer | null | yes | |
currentStepNotStarted | boolean | yes | The run is at a phase that has produced no heartbeat, so it renders `stalled` rather than `current`. Derived ONCE server-side so every pane reads the same answer. |
parkNote | string | null | yes | Non-null only while the run is paused on a recorded wait: what it is waiting on, whether it resumes by itself, and whether waiting or cancelling is the better move. When set, it is the note the page shows on the current step. |
isTerminal | boolean | yes | |
awaitingManualMerge | boolean | yes | A done run whose deliberately-unmerged PR still awaits a human. |
skippedStepNote | string | yes | The note a `skipped` Merge step renders. |
mergeStepNote | string | null | yes | Non-null only where the merge took the host-backstop path because the Checks permission was never granted. |
runKind | string | yes | |
seededFromRunId | string | null | yes | The run this one was seeded from, where it was. |
deferredFollowUpCount | integer | yes | |
discretionFollowUpCount | integer | yes | |
secondOrderFollowUpCount | integer | yes | |
diminishingReturnsFollowUpCount | integer | yes | |
unresolvedFollowUpCount | integer | yes | |
adjudicatedSetAsideCount | integer | yes | |
deferredOutOfDiffCount | integer | yes | |
unanchoredCount | integer | yes | |
toolingProposalCount | integer | yes | |
quarantinedCount | integer | yes | |
worthinessDemotedCount | integer | yes | |
selfReviewResidualCount | integer | yes | |
selfReviewResidualsWithAttempts | integer | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/runs/{runId}/activity
Operation customer.runs.activity. Read the steps one run took, as its activity page shows them.
The run's activity log under the key's own tenant: its attempts at each step, oldest first, with retries and earlier cycles after a restart included. IT PUBLISHES WHAT THE PAGE RENDERS, through the same read and the same projection, so the two cannot tell a customer different stories about one run, and a test pins that they call the same function. Each entry names its steps and its outcome in the page's own words. The mapped failure copy crosses on a failed attempt; the raw failure code does not. IT IS A BOUNDED TAIL, NOT THE WHOLE HISTORY: the run's most recent 500 attempts. Compare `attemptsRead` with `attemptsTotal` to tell a complete log from the tail of a longer one. Attempts earlier than the tail cannot be read through this operation, as they cannot on the page. A missing run and another tenant's run are one indistinguishable `not_found`, so this is not an oracle for what somebody else owns.
Security: apiKey (runs:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
runId | string | yes | |
entries | array of object | yes | Oldest first. Steps the page does not show on their own are folded into the entry before them, so there can be fewer entries than attempts read. |
entries[].from | string | yes | The step this attempt started from, in the words the run page uses. |
entries[].to | string | null | yes | The step it moved to. `null` when the attempt stayed on its step (a retry, a wait), and while the step it entered is still under way. |
entries[].outcome | "succeeded" | "notStarted" | "failed" | "retried" | "didNotComplete" | "paused" | "environmentUnavailable" | "inProgress" | "queued" | "didNothing" | "nothingToCheck" | "noTestsKept" | yes | How the attempt ended. `failed` is a step failure. `retried`, `didNotComplete`, `paused` and `notStarted` are not failures: the run carried on or waits to. `inProgress` and `queued` are still open. `didNothing`, `nothingToCheck`, `noTestsKept` and `environmentUnavailable` are checks that checked nothing, and never read as a pass. |
entries[].outcomeText | string | yes | The outcome in words, as the page shows it: with the cause on a failed attempt, the reason on a retried one, and the count on a check that checked nothing. |
entries[].attempt | integer | yes | Which try of this step it was, counted from 1. |
entries[].startedAtMs | integer | yes | |
entries[].completedAtMs | integer | null | yes | `null` while the attempt is still open. |
entries[].afterRestart | boolean | yes | True on the first entry of a new cycle after the run was restarted. Entries before it belong to an earlier cycle. |
attemptsRead | integer | yes | How many of the run's attempts this log was built from: the most recent ones, up to a fixed bound. |
attemptsTotal | integer | yes | Every attempt the run has recorded. Greater than `attemptsRead` when the log is the tail of a longer one. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/runs/{runId}/cancel
Operation customer.runs.cancel. Stop a run that should not be running.
The write that stops spend. Answers WHICH ending it reached rather than a boolean: `cancel_requested` is recorded-but-not-yet-stopped and calls for a re-read, while `already_finished` is final and calling again will never change it. Refusals keep their own discriminant, and an `outcome_unknown` answer means the cancel MAY have landed: verify the run before calling again. A deployment that is not wired to the runner answers `internal_error`, which is an operator problem and not something a caller can fix by retrying.
Security: apiKey (runs:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes | |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
outcome | "cancelled" | "cancel_requested" | "already_finished" | "privately_dispatched" | yes | How the cancel ended. `cancelled` is stopped. `cancel_requested` is RECORDED BUT NOT YET STOPPED — re-read the run rather than calling again. `already_finished` means there was nothing to stop and never will be. `privately_dispatched` means the work left this estate and cannot be recalled from here. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 502 | application/json | upstream_auth — An upstream service rejected this deployment's credential. (legacy `{ error }` body) |
| 503 | application/json | upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
POST /api/v1/runs/{runId}/restart
Operation customer.runs.restart. Restart a run that ended failed.
Re-enters the pipeline for a run that ended `failed` — the SAME run, so there is no new run id; re-read the run to watch it move. `awaitingSlot: true` means the restart is recorded and waits for a free run slot. `conflict` means the run or its repository is not in a state that admits a restart right now: the run is not `failed`, its repository is paused, another run is already working the issue, a restart is already in progress, the platform has paused new work, or the run's ending is one a restart cannot answer. It describes the state now and is not a promise that restarting can never succeed: re-read the run (`customer.runs.get`) and its repository (`customer.repos.list`), remedy what they show — resume a paused repository, for example — or wait for the competing run or the pause to clear, then restart again. A restart re-bills pipeline work, so the key's CREATOR must currently hold a role that may restart runs (today, an owner) in the key's organization: a key created by anyone else, or with no recorded creator, is refused with `forbidden`. An `outcome_unknown` answer means the restart MAY have landed: re-read the run before calling again.
Security: apiKey (runs:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes | |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
accepted | true | yes | The restart was accepted. Re-read the run to watch it re-enter the pipeline. |
awaitingSlot | boolean | yes | `true` when the restart is recorded but waits for a free run slot, so it has not started yet; it starts when one frees, with no further call. `false` when it started at once, or a restart for this attempt was already admitted. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 502 | application/json | upstream_auth — An upstream service rejected this deployment's credential. (legacy `{ error }` body) |
| 503 | application/json | upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
GET /api/v1/runs/{runId}/split-scorecard
Operation customer.runs.split-scorecard-get. Read a decomposing run's split scorecard, as its run page shows it.
What each leaf of the run's split went on to do, and what the split's outcomes say about where it was cut, under the key's own tenant. IT PUBLISHES WHAT THE RUN PAGE SHOWS, through the same read, so the two cannot tell a customer different stories about one split, and a test pins that they call the same function. Branch on `access`, then on `kind`. `absent` is a run that never decomposed; `unreadable` is one that did whose stored record cannot be read. Both are answers, not errors, and neither is the other. Every field of a scorecard is repository content, so a key whose creator may not read the run's repository gets `access: "summary"` and no scorecard. A leaf with no run reads `unobserved`, never as a zero, and a count after a restart is a floor; the fields say which. A missing run and another tenant's run are one indistinguishable `not_found`, so this is not an oracle for what somebody else owns.
Security: apiKey (runs:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
access | "content" | yes | |
kind | "scorecard" | yes | |
scorecard | object | yes | The derived scorecard for one decomposition. Computed on read, never stored. |
scorecard.sourceIssueExternalId | string | yes | The issue the run split. Repository content. |
scorecard.leaves | array of object | yes | Every leaf of the split, in the split's order. |
scorecard.leaves[].ordinal | integer | yes | The leaf's position in the split, counted from 1. |
scorecard.leaves[].title | string | yes | The leaf's title as the split wrote it. Repository content. |
scorecard.leaves[].filedIssueNumber | integer | null | yes | The issue the leaf was filed as. `null` when the filing never landed. |
scorecard.leaves[].outcome | object | yes | What the leaf's own runs did, summed over every run it has had. |
scorecard.leaves[].outcome.kind | "unobserved" | yes | |
scorecard.leaves[].outcome.kind | "observed" | yes | |
scorecard.leaves[].outcome.resolveIterations | integer | yes | Review rounds spent resolving feedback, summed over the leaf's runs. |
scorecard.leaves[].outcome.ciFixCycles | integer | yes | Repair cycles on the repository's own checks, summed over the leaf's runs. |
scorecard.leaves[].outcome.driftRebaseCount | integer | yes | Base-drift laps entered, summed over the leaf's runs. |
scorecard.leaves[].outcome.driftAbortCount | integer | yes | Base-drift laps that stopped on a fault before pushing, summed over the leaf's runs. |
scorecard.leaves[].outcome.restarted | boolean | yes | Whether any of the leaf's runs was restarted. A restart zeroes the counters above, so when true they are a floor, not a total. |
scorecard.leaves[].outcome.reachedDecomposing | boolean | yes | Whether any of the leaf's runs was itself split: the original cut was not a cut. |
scorecard.leaves[].outcome.mergedSize | object | null | yes | The leaf's pull request size as it merged. `null` when no measurement is on record, for whatever reason. |
scorecard.leaves[].outcome.mergedSize.additions | integer | yes | |
scorecard.leaves[].outcome.mergedSize.deletions | integer | yes | |
scorecard.leaves[].outcome.state | string | null | yes | The state of the leaf's latest run. Open by design, as `customer.runs.get` reports it. `null` when the stored state is not one this server recognises. |
scorecard.leaves[].outcome.generations | array of object | yes | The spans the leaf was working in, oldest run first. Empty means no span could be bounded, not that none happened. |
scorecard.leaves[].outcome.generations[].generation | integer | yes | Which cycle of its run this was: 0 for a run never restarted. Unique only within one run, so two windows of a leaf can share it. |
scorecard.leaves[].outcome.generations[].activeFrom | string | yes | When the cycle began. Inclusive. |
scorecard.leaves[].outcome.generations[].activeThrough | string | yes | When it stopped, at the earliest bound the record supports. Inclusive. |
scorecard.leaves[].outcome.generations[].counters | object | yes | The churn this window can be held to, or the statement that a restart destroyed it. |
scorecard.leaves[].outcome.generations[].counters.kind | "whole" | yes | |
scorecard.leaves[].outcome.generations[].counters.ciFixCycles | integer | yes | Repair cycles spent on the repository's own checks in this window. |
scorecard.leaves[].outcome.generations[].counters.driftRebaseCount | integer | yes | Times the leaf's base moved under it and a reposition was ATTEMPTED. Counts attempts, not repositions that pushed. |
scorecard.leaves[].outcome.generations[].counters.driftAbortCount | integer | yes | Of those, the ones that stopped on a fault before pushing. A subset of `driftRebaseCount`, so never add the two. A lap stopped by a conflict is not counted here. |
scorecard.leaves[].outcome.generations[].counters.kind | "reset" | yes | |
scorecard.leaves[].outcome.mergeWindow | object | yes | When the leaf's pull request merged, as an interval: the record carries no merge instant. |
scorecard.leaves[].outcome.mergeWindow.kind | "none" | yes | |
scorecard.leaves[].outcome.mergeWindow.kind | "unbounded" | yes | |
scorecard.leaves[].outcome.mergeWindow.kind | "bounded" | yes | |
scorecard.leaves[].outcome.mergeWindow.intervals | array of object | yes | Every bounded merge the leaf recorded, oldest first and never empty: a restarted leaf can merge more than once. |
scorecard.leaves[].outcome.mergeWindow.intervals[].notBefore | string | yes | The merge landed at or after this instant. |
scorecard.leaves[].outcome.mergeWindow.intervals[].notAfter | string | yes | ...and at or before this one. |
scorecard.leaves[].sizeBand | object | yes | Where the leaf's merged size falls among the repository's merged leaves. See `sizeBandBasis`. |
scorecard.leaves[].sizeBand.kind | "unsized" | yes | |
scorecard.leaves[].sizeBand.kind | "unbanded" | yes | |
scorecard.leaves[].sizeBand.totalLines | integer | yes | |
scorecard.leaves[].sizeBand.kind | "banded" | yes | |
scorecard.leaves[].sizeBand.band | "thin" | "target" | "oversized" | yes | Relative to this repository's own merged leaves, never an absolute size: `target` means unremarkable here. |
scorecard.leaves[].sizeBand.totalLines | integer | yes | |
scorecard.observedLeafCount | integer | yes | How many leaves have a run to measure at all. |
scorecard.ciFixLeafCount | integer | yes | Observed leaves that needed at least one repair cycle. A LOWER BOUND: read it beside `partialCounterLeafCount`. |
scorecard.partialCounterLeafCount | integer | yes | Observed leaves that were restarted, so their counters are a floor. Non-zero means `ciFixLeafCount` may be loose. |
scorecard.findings | array of object | yes | What the split's outcomes say about where it was cut. Empty when nothing was observed. |
scorecard.findings[].kind | "re-decomposed" | "missed-serialize" | "missed-blocked-by" | yes | `re-decomposed`: a leaf was itself split. `missed-serialize` and `missed-blocked-by`: a sibling merged while this leaf was still working, and this leaf paid for it. |
scorecard.findings[].severity | "hard" | "soft" | yes | `hard` admits no innocent reading. `soft` is an observation over one split's outcomes, never a verdict. |
scorecard.findings[].ordinals | array of integer | yes | The leaves the finding concerns, ascending. |
scorecard.findings[].subjectOrdinal | integer | yes | The leaf the finding is ABOUT, always one of `ordinals`: the one that paid, or the one that was itself split. `ordinals` is ascending, so only this carries direction. |
scorecard.findings[].detail | string | yes | One sentence naming what was observed. Repository content. |
scorecard.sizeBandBasis | object | yes | The population every leaf's band was judged against, reported once so a reader can check the band rather than trust it. |
scorecard.sizeBandBasis.kind | "insufficient-sample" | yes | |
scorecard.sizeBandBasis.sampleSize | integer | yes | |
scorecard.sizeBandBasis.minimumSampleSize | integer | yes | |
scorecard.sizeBandBasis.kind | "measured" | yes | |
scorecard.sizeBandBasis.sampleSize | integer | yes | |
scorecard.sizeBandBasis.thinBelowLines | integer | yes | Strictly below this many lines is `thin`: the population's 10th percentile. |
scorecard.sizeBandBasis.oversizedAboveLines | integer | yes | Strictly above this many lines is `oversized`: the population's 90th percentile. |
access | "content" | yes | |
kind | "absent" | yes | |
access | "content" | yes | |
kind | "unreadable" | yes | |
access | "summary" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/repos
Operation customer.repos.list. List this key's tenant's repositories.
Read-only, and the entry point for every repo-scoped operation: the `id` each of those takes is obtainable nowhere else on the programmatic surface. The key's tenant is taken from the matched key row and never from the request, so a key cannot list another tenant's repositories. Rate limited per source BEFORE authentication, so an unauthenticated caller cannot probe cheaply. Unpaginated — the whole list comes back.
Security: apiKey (repos:read)
Response
| field | type | required | description |
|---|---|---|---|
repos | array of object | yes | |
repos[].id | string | yes | The tenant-repo id. What every repo-scoped operation takes. |
repos[].owner | string | yes | The account or organization that owns the repository on its provider. |
repos[].repo | string | yes | The repository name, without the owner. |
repos[].onboardedStatus | string | yes | Where this repository is in onboarding, as the column stores it. `pending` until both installation ids are populated and the validation smoke test succeeds, then `active`; `disconnected` is the terminal state after self-serve offboarding. NOT a closed set — the column carries no constraint, so treat an unrecognised value as unknown rather than as an error. |
repos[].paused | boolean | yes | Whether the customer has paused this repository. A paused repository is not dispatched. |
repos[].installations | object | yes | Which of the two GitHub Apps this repository has a recorded installation for. Recorded state only: reading it runs no validation and asks GitHub nothing. |
repos[].installations.author | "installed" | "absent" | yes | The author App, which opens the pull requests. `installed` when an installation id is recorded for it. |
repos[].installations.review | "installed" | "absent" | yes | The review App, which reviews them. `installed` when an installation id is recorded for it. |
repos[].lastVerifiedAt | string | null | yes | When a successful validation last verified both installations, as an ISO 8601 timestamp; `null` when none has. Recorded state: reading it does not verify anything. |
repos[].onboardingCompletedAt | string | null | yes | When validation first succeeded for this repository, as an ISO 8601 timestamp; `null` until it has. Set on that first success and never reset. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
POST /api/v1/repos
Operation customer.repos.connect. Connect a repository to this tenant.
The first step of onboarding, and the one every other repository operation needs: nothing else here can be called until a repository has an id, and an id exists only once it is connected. The tenant is the key's own — there is no tenant in the path, because a key resolves exactly one. Coverage is verified against the Apps themselves rather than read from a stored column. A repository the AUTHOR App cannot reach cannot be bound at all. A repository the REVIEW App cannot reach IS bound — the binding is real and will be picked up — and `awaiting` carries `review-app` so the caller knows nothing will stamp the review until that App is installed or widened. That is deliberate and matches what the console already does for the same customer: refusing here would make this API reject what the console accepts. A coverage lookup that did not COMPLETE is `upstream_unavailable` — nothing is known either way and the remedy is to retry, not to reinstall. `github_link_required` means the account that created the key has no usable GitHub link, never linked or needing to be reconnected, so nothing could be looked up: retrying is not expected to help until that account links or reconnects GitHub. Nothing was written, so it releases its Idempotency-Key, and once the link exists a retry under the same key runs again. A connected binding lands `pending`, NOT dispatching: read `awaiting` on the response for what is still owed. RE-CONNECTING A REPOSITORY THIS TENANT ALREADY HOLDS IS ACCEPTED, not refused: it answers 200 with `changed: false`. **That is not the same as nothing happening** — the bind rewrites the stored author App installation id on the existing row, and the review id when one is found, which repairs a binding whose ids had gone stale. It repairs THE IDS ONLY: a `suspended` or `revoked` binding is not cleared by re-connecting, and `customer.repos.activate` refuses it — read `onboardedStatus` to tell which case you are in. `changed: false` says you already HELD this repository; it does not say the call was inert, and it never means the binding was replaced. Branch on `changed`, NOT on an error — there is no `conflict` for this case and a caller that writes one has written dead code. That makes `connect` safe to retry, which matters for a call that creates a binding over a network that can drop the response. The OPERATOR path differs deliberately and refuses a repeat: an operator acting on someone else's tenant should be told the binding already exists, where a customer re-running their own setup should simply find it done.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
owner | string | yes | The repository's owner, as typed; surrounding whitespace is trimmed before it is validated. GitHub's own casing is what gets written and reported. |
repo | string | yes | The repository name, without the owner. |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | |
repo.owner | string | yes | |
repo.repo | string | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
awaiting | array of "activation" | "unpause" | "review-app" | yes | What remains before this repository completes a run. EMPTY means nothing is owed. `activation` means `onboardedStatus` is not yet `active`. READ `repo.onboardedStatus` BEFORE ACTING ON IT: if it is `pending`, `customer.repos.activate` finishes the onboarding; if it is `suspended` or `revoked`, that operation REFUSES `conflict`, because clearing those is a repair: `customer.repos.validate` performs it, re-checking that both Apps reach the repository before it returns it to `active`. The member says the scheduler will not pick the repository up, not that you can always clear it yourself. `unpause` means `paused` is still true — clear it with `customer.repos.update`. `review-app` means the Descant REVIEW App does not reach this repository: the binding is real and the repository will be picked up, but nothing will stamp the review, so install or widen that App on the owner account. Any combination can be present at once, and clearing only some of them leaves the repository stuck with no error raised anywhere. |
changed | boolean | yes | Whether this request actually moved anything. FALSE is a success, not a refusal. On `activate` it means the repository was already `active`, so no column moved — the trail still records that you asked, exactly as the console's own control does when pressed on an active row. On `connect` it means this tenant already held a binding for this repository, answered 200 rather than 201. The call still rewrote the stored installation ids on that row, so `changed: false` means "you already had this repository" rather than "nothing was written". |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | conflict — The resource is not in a state that admits this. / github_link_required — The account that created this credential has no usable GitHub link; retrying will not help until one is linked or reconnected. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 503 | application/json | upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
POST /api/v1/repos/{id}/activate
Operation customer.repos.activate. Activate a connected repository.
FINISHES AN ONBOARDING — it does not simply make a repository active, and the difference is the boundary of this operation. It moves a `pending` binding to `active`. It does NOT reactivate a `suspended` or `revoked` one: that is a REPAIR, refused `conflict` here, and it lives on the operator path. Re-establishing what `active` asserts needs a live check of BOTH Apps' coverage, a refusal when the stored installation ids are gone, and a fence against the row moving underneath — none of which this operation performs, so a binding suspended BECAUSE its coverage was lost would otherwise be made schedulable by a call that never looked at coverage, and the scheduler would claim it every cadence and discard the work at seed with nothing raising an error. A `disconnected` or `detached` binding is also `conflict`; it needs re-onboarding. It writes ONE of the two columns the scheduler claims on and does NOT touch `paused` — a repository can be active and still idle, which is why `awaiting` is on every landed response and why a caller that reads only this operation's success can hold a repository that never dispatches. Clear `paused` with `customer.repos.update`. A repeat on a repository that is already active is a 200 answering `changed: false` and still reports the current state, exactly as the console's own control does when pressed on an active row. A repository this tenant does not hold is one indistinguishable 404, never a hint that it exists elsewhere.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | |
repo.owner | string | yes | |
repo.repo | string | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
awaiting | array of "activation" | "unpause" | "review-app" | yes | What remains before this repository completes a run. EMPTY means nothing is owed. `activation` means `onboardedStatus` is not yet `active`. READ `repo.onboardedStatus` BEFORE ACTING ON IT: if it is `pending`, `customer.repos.activate` finishes the onboarding; if it is `suspended` or `revoked`, that operation REFUSES `conflict`, because clearing those is a repair: `customer.repos.validate` performs it, re-checking that both Apps reach the repository before it returns it to `active`. The member says the scheduler will not pick the repository up, not that you can always clear it yourself. `unpause` means `paused` is still true — clear it with `customer.repos.update`. `review-app` means the Descant REVIEW App does not reach this repository: the binding is real and the repository will be picked up, but nothing will stamp the review, so install or widen that App on the owner account. Any combination can be present at once, and clearing only some of them leaves the repository stuck with no error raised anywhere. |
changed | boolean | yes | Whether this request actually moved anything. FALSE is a success, not a refusal. On `activate` it means the repository was already `active`, so no column moved — the trail still records that you asked, exactly as the console's own control does when pressed on an active row. On `connect` it means this tenant already held a binding for this repository, answered 200 rather than 201. The call still rewrote the stored installation ids on that row, so `changed: false` means "you already had this repository" rather than "nothing was written". |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
POST /api/v1/repos/{id}/disconnect
Operation customer.repos.disconnect. Disconnect a connected repository, confirmed by its owner/name.
Ends this tenant's binding to a repository, the way the repository settings page's Disconnect does and through the same writer: polling stops, in-flight runs are force-failed and the repository's claims are released. `customer.repos.reconnect` takes it back. `confirmRepo` must echo the repository's `owner/name`; a mismatch is `conflict` too, and nothing changes. A repository this tenant does not hold and one outside what the key's creator may see are one indistinguishable `not_found`. One already disconnected or detached is `conflict`, as on `customer.repos.activate`: the binding is there, its status is readable on `GET /api/v1/repos/{id}`, and nothing is left to disconnect. The drain inside a disconnect is bounded, so a 200 can report `teardown: "draining"`: the repository is disconnected and some runs are still winding down. `outcome_unknown` is a disconnect that was sent and did not provably fail, so the repository may already be disconnected: read it with `GET /api/v1/repos/{id}` before anything else. To disconnect several repositories, call this once per repository, each with its own `owner/name`; the console's bulk disconnect does the same, one repository at a time through this writer.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
confirmRepo | string | yes | The repository's `owner/name`, exactly as `GET /api/v1/repos` reports it. The runner compares it against the repository it resolves for this id; a mismatch is `conflict` and nothing is disconnected. |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | |
repo.owner | string | yes | |
repo.repo | string | yes | |
teardown | "converged" | "draining" | yes | Whether every run had stopped when the disconnect answered. `converged`: the repository is disconnected and nothing is running. `draining`: the repository IS disconnected, and some runs outlived the bounded drain and are still winding down. Either way it is no longer connected, so a repeat answers `conflict` rather than draining again. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
POST /api/v1/repos/{id}/reconnect
Operation customer.repos.reconnect. Reconnect a disconnected repository.
Takes back a disconnect, the way the account page's Reconnect does and through the same steps: the repository is re-proved on GitHub for the key's creator exactly as `customer.repos.connect` proves a new one (both Apps' coverage, the owned-elsewhere and case-clash refusals, the member gate, review discovery), and then the binding moves from `disconnected` back to `pending`. It is NOT active yet: like a new connection it still owes validation, so read `awaiting` and finish with `customer.repos.validate` or `customer.repos.activate`. A repository this tenant does not hold and one outside what the key's creator may see are one indistinguishable `not_found`. `conflict` means the current state refuses a reconnect, and nothing changed: the repository is not disconnected (a repeat after a reconnect that landed answers this), an organization admin detached it, the account is closed, its runs are still winding down from the disconnect, or its name on GitHub changed since it was disconnected; read it with `GET /api/v1/repos/{id}`. `upstream_unavailable` is a coverage lookup that did not complete, so retry. `github_link_required` means the account that created the key has no usable GitHub link, so nothing could be looked up: retrying is not expected to help until that account links or reconnects GitHub, and the answer releases its Idempotency-Key. A 5xx answered before anything was written releases its Idempotency-Key, so a retry under the same key runs again. `outcome_unknown` is a reconnect that was sent and did not provably fail: read the repository before sending a new Idempotency-Key.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | |
repo.owner | string | yes | |
repo.repo | string | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
awaiting | array of "activation" | "unpause" | "review-app" | yes | What remains before this repository completes a run. EMPTY means nothing is owed. `activation` means `onboardedStatus` is not yet `active`. READ `repo.onboardedStatus` BEFORE ACTING ON IT: if it is `pending`, `customer.repos.activate` finishes the onboarding; if it is `suspended` or `revoked`, that operation REFUSES `conflict`, because clearing those is a repair: `customer.repos.validate` performs it, re-checking that both Apps reach the repository before it returns it to `active`. The member says the scheduler will not pick the repository up, not that you can always clear it yourself. `unpause` means `paused` is still true — clear it with `customer.repos.update`. `review-app` means the Descant REVIEW App does not reach this repository: the binding is real and the repository will be picked up, but nothing will stamp the review, so install or widen that App on the owner account. Any combination can be present at once, and clearing only some of them leaves the repository stuck with no error raised anywhere. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | conflict — The resource is not in a state that admits this. / github_link_required — The account that created this credential has no usable GitHub link; retrying will not help until one is linked or reconnected. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 503 | application/json | upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
POST /api/v1/repos/{id}/validate
Operation customer.repos.validate. Prove a connected repository's install, and make it active.
Runs the repository's install validation, the onboarding validate page's check, through the same steps: both Descant Apps mint a token and must reach the repository, a verification issue is opened, commented on and closed in it, and the repository moves to `active`. Unlike `customer.repos.activate` it PROVES the install first, so it also returns a `suspended` or `revoked` repository to `active` once both Apps reach it again. It does not touch `paused`. A repository this tenant does not hold, one outside what the key's creator may see, and one already disconnected or detached are one indistinguishable `not_found`, and nothing is written for any of them. `conflict` means the install itself is not right — an App is not installed, or does not reach the repository, or the repository was suspended while the check ran — and `detail` names what to fix; nothing is activated, so fix the install and call again (under a new Idempotency-Key, if you sent one: a `conflict` is stored and replayed like any answer). `upstream_rate_limited` carries the interval to wait. `upstream_auth` and `internal_error` are this deployment's to fix, not yours. A 5xx answered before the verification issue was opened releases its Idempotency-Key, so a retry under the same key runs again; one answered after it does not, and a retry under that key answers `outcome_unknown` — read the repository with `GET /api/v1/repos/{id}` before sending a new key. A repeat on an active repository is a 200 with `transitionedToActive: false`, and opens and closes one more verification issue.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
issueNumber | integer | yes | The number of the verification issue Descant opened, commented on and closed in the repository. |
issueUrl | string | yes | The verification issue's page on GitHub. |
transitionedToActive | boolean | yes | Whether this call moved the repository to `active`. FALSE is a success, not a refusal: the repository was already active and this call re-proved its install. |
labelCreationWarning | string | no | Present when the eligibility label could not be created in the repository: the validation still succeeded, and this names the label to create by hand. ABSENT when the label was created, was already there, or is not needed because the repository picks every issue. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_unavailable — A service this operation depends on did not answer. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/repos/{id}
Operation customer.repos.get. Read one of this key's tenant's repositories by id.
The same fields `customer.repos.list` returns for this repository, read by its id, recorded onboarding facts included: reading them runs no validation and asks GitHub nothing. The key's tenant is taken from the matched key row and never from the request: an id this tenant does not hold answers `not_found`, exactly as a malformed or unknown one does. Read-only.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | The tenant-repo id. What every repo-scoped operation takes. |
repo.owner | string | yes | The account or organization that owns the repository on its provider. |
repo.repo | string | yes | The repository name, without the owner. |
repo.onboardedStatus | string | yes | Where this repository is in onboarding, as the column stores it. `pending` until both installation ids are populated and the validation smoke test succeeds, then `active`; `disconnected` is the terminal state after self-serve offboarding. NOT a closed set — the column carries no constraint, so treat an unrecognised value as unknown rather than as an error. |
repo.paused | boolean | yes | Whether the customer has paused this repository. A paused repository is not dispatched. |
repo.installations | object | yes | Which of the two GitHub Apps this repository has a recorded installation for. Recorded state only: reading it runs no validation and asks GitHub nothing. |
repo.installations.author | "installed" | "absent" | yes | The author App, which opens the pull requests. `installed` when an installation id is recorded for it. |
repo.installations.review | "installed" | "absent" | yes | The review App, which reviews them. `installed` when an installation id is recorded for it. |
repo.lastVerifiedAt | string | null | yes | When a successful validation last verified both installations, as an ISO 8601 timestamp; `null` when none has. Recorded state: reading it does not verify anything. |
repo.onboardingCompletedAt | string | null | yes | When validation first succeeded for this repository, as an ISO 8601 timestamp; `null` until it has. Set on that first success and never reset. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
PATCH /api/v1/repos/{id}
Operation customer.repos.update. Change whether a repository is paused, and what it picks.
A PARTIAL update: send only the fields you mean to change. An empty object is an accepted no-op answering `changed: false`, not a refusal — the published schema accepts it, so refusing it would make this contract disagree with the server about what is a valid body. An ABSENT field is left alone; a `null` `maxConcurrentRunsThisRepo` CLEARS the sub-cap, and a `null` `docRoots` RESETS the documentation directories to the default — both real writes rather than absences. A sub-cap above the account cap is inert, not refused — the effective cap is the lower of the two. `changed: false` is a success: re-sending the current state is an accepted no-op. Naming an eligibility label in `labeled` mode ALSO CREATES that label on the repository, best-effort and after the write itself has landed; if it could not be created the response still succeeds and carries `eligibilityLabelWarning`, whose ABSENCE means the label is there. In `all-issues` mode nothing is created, because the label gates nothing there. A write that names `eligibilityLabel`, `eligibilityMode` or `labelMap` also walks the stored ordering on the local copy, and if it now matches none of the issues the repository could pick up, the response still succeeds and carries `orderWalkWarning`. A label spelled like a label-mapping value is refused, and so is a label mapping spelled like the eligibility label. An eligibility or label-mapping change under which one of the stored ordering queries would match nothing is refused as `invalid_body`, as the settings page refuses it. A `labelMap` list REPLACES the stored label mapping and `null` or an empty list CLEARS it; the mapped labels are not created on the repository. The rest of what the settings page saves is here too: the base branch, auto-merge, whether assigned issues are skipped, whether a run may author workflow files, the area labels (`null` or an empty map CLEARS them) and the ordering queries (`null` restores the default walk). An ordering query or area map the settings page would refuse is refused as `invalid_body`, and a write naming `excludeAssigned`, `areaLabels` or `orderQueries` walks the ordering as an eligibility change does. The polling interval is not a repository setting a key can change. Read the stored settings back with the repo config read; this response carries the repository's own fields only.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
paused | boolean | no | Whether this repository is paused. A paused repository is not dispatched. |
automaticPickup | boolean | no | Whether Descant starts work on this repository's issues on its own. `false` stops automatic pickup: nothing is started here unless you start it, and a run you start by hand still works. Not the same as `paused`, which stops manual runs too. While it is on, the eligibility label and mode decide which issues are picked, as before. |
orderStrategy | "oldest-first" | "newest-first" | no | The direction the picker walks a tier. Built from the same `@descant/types` constant the runtime narrows against, so the contract and the picker cannot disagree about the vocabulary. |
maxConcurrentRunsThisRepo | integer | null | no | This repository's concurrency sub-cap. `null` CLEARS it, which is a real write and not an absence — the account cap alone then governs. The effective cap is `min(account, repo)`, so a value ABOVE the account cap is INERT rather than rejected: it will not raise anything. The upper bound is the column's own storage range, not a product ceiling. |
eligibilityLabel | string | no | The label an issue must carry to be eligible in `labeled` mode. Stored trimmed. IN `labeled` MODE THIS WRITE ALSO CREATES THE LABEL ON THE REPOSITORY, best-effort, once the write itself has landed — and when it could not, the response carries `eligibilityLabelWarning` naming the label, so you can create it by hand rather than wait on a pickup that will find nothing. In `all-issues` mode nothing is created, because the label gates nothing there: the value is stored for when you switch back. Refused: empty, a `P<digits>` priority label, a comma, `blocked` or `waiting`, a spelling a label-mapping value already uses, or a change under which one of the stored ordering queries would match nothing. |
eligibilityMode | "labeled" | "all-issues" | no | Which issues are candidates at all: `labeled` requires the eligibility label, `all-issues` considers every open issue. Switching to `all-issues` keeps the stored label, so switching back restores it. A change under which one of the stored ordering queries would match nothing is refused as `invalid_body`. |
docRoots | array of string | null | no | The directories in this repository whose documentation Descant keeps current. Before a change is approved, Descant may update files under these directories on the same pull request, and its documentation updates touch nothing outside them. Each is a path relative to the repository root, such as `docs/guides`; it is stored with one trailing `/`. `null` RESETS to the default, which is a real write and not an absence — the default is `docs/` alone. Plan documents under `docs/plans` are never included by a wider directory, the default included: to have them kept current, list `docs/plans` (or a directory inside it) yourself. Between 1 and 20 entries of at most 200 characters each, counting the trailing `/`. Refused: the repository root itself, a hidden directory or one inside it (any name starting with `.`, such as `.github/`), a path starting with `/`, a backslash, a `.` or `..` directory, an empty directory name, or two entries naming the same directory. Only Markdown documents (`.md`, `.markdown`) under these directories count as documentation: a documentation update that touches any other file, or an agent or tool instruction file such as `AGENTS.md` or `SKILL.md` wherever it sits, is not approved automatically. Setting a list REPLACES the stored one; it does not add to it. |
labelMap | object | null | no | The label mapping: which of the repository's own labels Descant reads and writes for each of its internal label keys, as a map from key to label. Keys: `P0`, `P1`, `P2`, `P3`, `type:bug`, `type:feature`, `type:chore`, `type:unassessed`. On the CLI, `--label-map <KEY>=<label>`, repeated. A key left out uses its own name as the label. OMIT to keep the stored mapping. Send `null` — or an empty map — to CLEAR every mapping; `null` is the spelling the generated CLI can send. A map REPLACES the stored mapping whole; it does not add to it. A key with an empty label (`P1=`) leaves that key unmapped. Refused as `invalid_body`, as the settings page refuses them (labels compare case-insensitively): an unknown key; a label over 50 characters; a label of the form `P<digits>` other than the key's own; a label starting with `area:`, `base:`, `complexity:`, `descant:` or `residual-fingerprint:`; `blocked`, `waiting` or `automation:needs-human`; a priority key (`P0`..`P3`) mapped to a type key's name (`type:bug`, `type:feature`, `type:chore`, `type:unassessed`); two priority keys mapped to one label; a type key mapped to a label a priority key uses; a label spelled like the eligibility label (and `type:unassessed` left unmapped while the eligibility label is `type:unassessed`); a PRIORITY key's label spelled like one of the repository's configured area labels; and a mapping under which one of the stored ordering queries would match nothing. Those last three are judged against the settings this write leaves: the stored ones wherever this patch does not change them. The rules apply to the whole map you send, so re-sending an unchanged stored mapping that holds a label only new writes refuse (`blocked`, `waiting`, `automation:needs-human`, `residual-fingerprint:`, or a priority key mapped to `type:unassessed`) is refused too, not accepted as a no-op (the settings page lets an unchanged mapping through; this write does not), so omit the field to leave it alone. THIS WRITE DOES NOT CREATE THE MAPPED LABELS ON THE REPOSITORY. |
defaultBaseBranch | string | no | The branch a run's pull request targets, stored as sent. Between 1 and 255 characters. Moving it releases the issues held because a run stopped on a base branch that did not exist, so the next pickup can try them again. |
autoMergeEnabled | boolean | no | Whether a run's pull request is merged automatically once it is green. Turning it on also comments on the pull requests this repository left open while it was off, best-effort and after the write has landed. |
excludeAssigned | boolean | no | Whether issues already assigned to someone are skipped by pickup. Descant can turn it off and back on by itself to recover work assigned to it, so the stored value is not always the last one written. A write naming it also walks the stored ordering, as `orderWalkWarning` describes. |
workflowsWriteEnabled | boolean | no | Whether a run may create or change workflow files in this repository. `false` means a change that needs one is refused rather than attempted. It does not change which issue is picked. |
areaLabels | object | null | no | The area labels: for each path prefix, the `area:*` label an issue Descant files about a change under it carries. On the CLI, `--area-labels <prefix>=<label>`, repeated. OMIT to keep the stored map. Send `null` — or an empty map — to CLEAR it. A map REPLACES the stored one whole. Each prefix and label is stored trimmed. Refused as `invalid_body`, as the settings page refuses them: an empty prefix or label, a label not starting with `area:`, the prefixes `__proto__`, `constructor` and `prototype`, two prefixes that are the same once trimmed, and a map under which one of the stored ordering queries would match nothing. |
orderQueries | array of string | null | no | The ordering queries: issue searches pickup walks in order, the first match winning, in place of the default walk. OMIT to keep the stored ones; send `null` to CLEAR them and restore the default walk; send a list to REPLACE them. Refused as `invalid_body`, as the settings page refuses them: an empty list, more than 10 queries, a query with a control character, one pickup cannot evaluate (including a term Descant sets itself, such as `is:open` or `repo:`), and one that names a label pickup always skips under the settings this write leaves. Try a list first with `customer.repos.order-preview`. A write naming it also walks the new ordering, as `orderWalkWarning` describes. |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | |
repo.owner | string | yes | |
repo.repo | string | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
changed | boolean | yes | Whether this request actually moved anything. FALSE is a success, not a refusal: re-sending the state a repository is already in is an accepted no-op, and the audit trail records the intent either way. |
eligibilityLabelWarning | string | no | A PARTIAL SUCCESS, never a refusal: the write landed, `changed` is unaffected, and only the attempt to create the eligibility label on the repository did not succeed. PRESENT means you must create that label on the repository by hand, or issues carrying it will never be picked up. ABSENT IS A CLAIM ABOUT THIS WRITE, AND ONLY ON A WRITE THAT ASSERTED ELIGIBILITY — one that named `eligibilityLabel`, or set `eligibilityMode` to `labeled`. There, absence confirms the label is on the repository: this write created it or found it already there. Anywhere else absence carries NO such claim and must not be read as one, because nothing looked: a patch that only pauses the repository, changes the order or sets the sub-cap never inspects the label, and neither does one that leaves the repository in `all-issues` mode, where the label gates nothing. To make the label checked, assert it — re-sending the same `eligibilityLabel` is a no-op write that still materialises. |
orderWalkWarning | string | no | A PARTIAL SUCCESS, never a refusal: the write landed and `changed` is unaffected. PRESENT means the repository's ordering queries, under the settings this write left, now match none of the open issues the repository could otherwise pick up, so pickup finds nothing and reports no error. Change them back or edit the ordering queries. Checked only on a write that names `eligibilityLabel`, `eligibilityMode`, `labelMap`, `excludeAssigned`, `areaLabels` or `orderQueries`, against Descant's local copy of the repository's issues. ABSENT CLAIMS NOTHING: the check is skipped on any other write, and it gives no verdict while that copy is still importing or is not confirmed current. To check a draft eligibility or label mapping before writing it, walk it with `customer.repos.order-preview`. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/settings-audit
Operation customer.repos.settings-audit. Read one repository's recorded settings intents, newest first.
Who paused or resumed this repository, when, and whether the flip changed anything — readable by a key-holding program without a browser session. System pauses appear alongside operator ones, told apart by `actor`. A FIXED WINDOW of the 50 newest rows with no paging, so an empty `entries` is NOT proof the repository was never paused: it means nothing was recorded within that window. This is not a `paused` state oracle; the current pause reason is on the poller-status read. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does — never a validation error, so a caller cannot use the status to learn which ids are well-formed.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
entries | array of object | yes | |
entries[].id | string | yes | |
entries[].actor | string | yes | Who recorded the intent, as an OPAQUE string. The write side owns this vocabulary and a reader must not branch on its prefix shape. System pauses appear here alongside operator ones — the scheduler's evidence-driven auto-pause appends its own row actored `service:scheduler-autopause` — and `actor` is what tells them apart. |
entries[].action | string | yes | The intent recorded, as the write side names it. |
entries[].changed | boolean | yes | Whether this intent was a REAL change rather than a no-op re-assertion of the state already held. |
entries[].detail | object | null | yes | The row's detail, crossing the wire VERBATIM. ALWAYS PRESENT: a pause or resume carries its field transition; a `detach` records `null`, and that explicit null IS the persisted state — never an absence, never collapsed or dropped. The values are unbounded on purpose: the contract does not own this shape. |
entries[].createdAt | string | yes |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/poller-status
Operation customer.repos.poller-status. Read why this repository is or is not being polled.
The read that answers 'why is nothing happening'. `status` is a discriminated union over EIGHTEEN causes, because 'not polling' has four different remedies and a boolean loses the difference: a repository HELD on its candidates, a worker that never consulted the ticket source, a billing pause, and a page already filed by earlier runs all look the same through `paused: true`. Branch on `status.kind`. `repo.paused` and `billing.paused` are the raw flags beneath that reading, and `lastPoll` is the stored tick beneath both. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does — never a validation error, so a caller cannot use the status to learn which ids are well-formed.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
status | object | yes | |
status.kind | "never-polled" | yes | |
status.kind | "no-eligible-issues" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "page-self-filed-paced" | yes | |
status.nextSlotAt | null | yes | Always `null`. It named the earliest instant the next paced run could start; the daily pace is gone, so there is no slot to wait for. |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "page-self-filed-only" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "not-onboarded" | yes | |
status.onboardedStatus | string | yes | |
status.kind | "billing-paused" | yes | |
status.reason | "balance" | "cap" | "operator" | null | yes | `balance`, `cap` or `operator`. `null` means the reason was absent OR was a value this deployment does not recognise — the handler degrades an unknown reason to null rather than leaking it, so null is 'paused, reason not established', never 'not paused'. |
status.kind | "paused-manual" | yes | |
status.lastPolledAt | string | null | yes | |
status.kind | "paused-auto" | yes | |
status.consecutiveFailures | integer | yes | |
status.lastErrorClass | "rate-limited" | "auth" | "other" | null | yes | |
status.lastPolledAt | string | null | yes | |
status.kind | "pickup-off" | yes | |
status.lastPolledAt | string | null | yes | |
status.kind | "held-page-exhausted" | yes | |
status.refusals | object | null | yes | What the refused page was waiting on. `null` when the last poll predates the record, or the stored value could not be read; the repository is held either way. |
status.refusals.examined | integer | yes | How many candidates the walk examined and refused. |
status.refusals.reasons | object | yes | How many candidates each refusal refused, keyed by the refusal's code: a skip reason, or a finer code naming what releases it where the reason alone does not (for example `issue-answered-awaiting-blockers`, an issue waiting for work it depends on to land). |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "held-walk-stopped" | yes | |
status.hold | object | null | yes | Which hold stopped the walk. `null` when the last poll predates the record, or the stored value could not be read; the repository is held either way. |
status.hold.issue | string | yes | The holding issue's number on the code host, as a string. |
status.hold.reason | "manual-merge-pending" | "manual-merge-probe-unavailable" | "done-event-unrecognized" | yes | `manual-merge-pending`: its pull request waits on your merge. `manual-merge-probe-unavailable`: its pull request's state could not be read. `done-event-unrecognized`: its last run ended in a state this deployment cannot classify, so it is held to be safe. |
status.hold.prNumber | integer | null | yes | The held pull request's number, or `null` when none is known. |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "pickup-not-wired" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "seeded" | yes | |
status.runId | string | null | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "seeded-waiting-slot" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "invalid-order-query" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "error" | yes | |
status.errorClass | "rate-limited" | "auth" | "other" | null | yes | |
status.retriable | boolean | yes | |
status.consecutiveFailures | integer | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "skipped" | yes | |
status.reason | string | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since; on `page-self-filed-paced`, waiting-since (both historical). `null` when the outcome carries no such instant. |
status.kind | "unknown-outcome" | yes | |
status.raw | string | null | yes | |
status.lastPolledAt | string | yes | |
lastPoll | object | yes | The last tick's stored fields, beneath the classification. Use `status` unless you need what was persisted. |
lastPoll.polledAt | string | null | yes | |
lastPoll.outcome | string | null | yes | The RAW stored outcome, unclassified. `status` is the reading of it. |
lastPoll.errorClass | string | null | yes | The raw stored error class, unnarrowed — unlike `status.errorClass`. |
lastPoll.seededRunId | string | null | yes | |
lastPoll.outcomeAt | string | null | yes | |
lastPoll.consecutiveTicketSourceUnavailableCount | integer | yes | |
repo | object | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
billing | object | yes | |
billing.paused | boolean | yes | |
billing.reason | "balance" | "cap" | "operator" | null | yes | Why billing paused this tenant, from the closed set the handler narrows to. `null` is 'paused, reason not established' — absent or unrecognised — never 'not paused'. |
billing.degraded | boolean | yes | Whether the billing read itself was degraded — so `paused` and `reason` may be stale rather than wrong. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/config
Operation customer.repos.config. Read what this repository is configured to pick, and in what order.
The rule behind the other two repo reads: which issues are candidates, which label makes one eligible, whether assigned issues are excluded, and the order the picker walks. Values are EFFECTIVE — the defaults are composed in when no row is stored, and no flag distinguishes the two, because they behave identically. `effectiveOrderQueries` is derived and must never be written back. Also the documentation directories Descant keeps current, stored (`docRoots`) and effective (`effectiveDocRoots`, likewise derived), the stored label mapping (`labelMap`), and three settings the repo update writes that are not pickup policy: the base branch, the area labels and whether a run may author workflow files. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
config | object | yes | |
config.eligibilityLabel | string | yes | The label an issue must carry to be eligible in `labeled` mode. Not consulted in `all-issues` mode. |
config.eligibilityMode | "labeled" | "all-issues" | yes | Which issues are candidates at all. `labeled` requires the eligibility label; `all-issues` considers every open issue, and is why the diagnostics read reports no label-bucketed counts in that mode. |
config.excludeAssigned | boolean | yes | Whether assigned issues are excluded. NOT durable operator policy: this is an AUTO-MANAGED recovery knob the reconciler can flip and restore without any operator write, so `false` must not be read as 'someone chose this'. |
config.orderStrategy | "oldest-first" | "newest-first" | yes | The direction the picker walks a tier: oldest issue first, or newest first. |
config.orderQueries | array of string | null | yes | The persisted walk, verbatim, or `null` meaning the mode's default walk. `null` is the common case and is not an error. |
config.effectiveOrderQueries | array of string | yes | DERIVED, NOT STORED — what the picker actually walks: `orderQueries` when set, otherwise the mode-aware default (the P0-P3 tiers plus the unlabeled final tier). Never write this back as configuration; `orderQueries` is the field that is set. |
config.docRoots | array of string | null | yes | The documentation directories this repository declared, verbatim as stored (each ending in `/`), or `null` meaning the default. `null` is the common case and is not an error. An EMPTY list means no directories at all: nothing is kept current. Only a personal-data erasure that removed every declared directory produces it; setting a list, or `null`, replaces it. |
config.effectiveDocRoots | array of string | yes | DERIVED, NOT STORED — the directories whose documentation Descant actually keeps current: `docRoots` when set (including an empty list, which keeps none current), otherwise the default. Plan documents under `docs/plans` are kept current only when a listed directory is `docs/plans` or one inside it, never through a wider one such as `docs/`. Never write this back as configuration; `docRoots` is the field that is set. |
config.labelMap | object | yes | The stored label mapping: for each of Descant's internal label keys that is mapped, the repository's own label Descant reads and writes in its place. Sparse — a key that is absent uses its own name as the label, so an empty object means no mapping at all. To change it, send the same shape as `labelMap` to the repo update. |
config.autoMergeEnabled | boolean | yes | Whether a run's pull request is merged automatically once it is green. |
config.automaticPickup | boolean | yes | Whether Descant starts work on this repository's issues on its own. `false` means nothing is picked up automatically; runs started by hand still work. |
config.defaultBaseBranch | string | yes | The branch a run's pull request targets. |
config.areaLabels | object | yes | The stored area labels: for each path prefix, the `area:*` label an issue Descant files about a change under it carries. An empty object means none. Not pickup policy; to change it, send the same shape as `areaLabels` to the repo update. |
config.workflowsWriteEnabled | boolean | yes | Whether a run may create or change workflow files in this repository. Not pickup policy: it cannot change which issue is picked, only what a run may write once one is. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/issues
Operation customer.repos.issues.list. List a repository's open issues, as the enqueue picker sees them.
The issues a run can be started for, read through this deployment's author App on the repository the path names — the same listing the console's picker renders. `configured: false` is a SUCCESS and means this deployment cannot enumerate (no author-App credentials, or this repository's author installation never landed); enqueue still works with an issue number you supply, so branch on the field rather than on the status. A malformed id, an unknown id and another tenant's id are one indistinguishable `not_found`, answered with no upstream call. Upstream failures are classified rather than proxied: `upstream_rate_limited` carries its interval and is worth retrying, `upstream_auth` is this deployment's credential and not yours to fix.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
configured | true | yes | |
issues | array of object | yes | |
issues[].number | integer | yes | The issue number as the provider names it — what an enqueue takes. |
issues[].title | string | yes | |
issues[].labels | array of string | yes | The issue's labels, for a caller filtering on the eligibility label before it enqueues. |
issues[].bodySnippet | string | yes | The opening of the issue body, enough to recognise it in a list. Never the whole body. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. |
GET /api/v1/repos/{id}/labels
Operation customer.repos.labels.list. List a repository's label names, as the eligibility-label setting reads them.
The label NAMES this repository carries — what `customer.repos.update` stores as the eligibility label, and what the poller matches an issue against, so this is the read that makes that setting writable without guessing. `configured: false` is a SUCCESS on the same terms as the issue picker: setting a label by name still works when enumeration is unavailable. The `not_found` and upstream-classification rules are that operation's; see it.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
configured | true | yes | |
labels | array of string | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. |
GET /api/v1/repos/{id}/poller-diagnostics
Operation customer.repos.poller-diagnostics. Explain why this repository's issues would or would not be picked.
A LIVE read: one provider call per request, never cached. Answers how many issues carry the eligibility label, how many pass the label and assignment filters, which buckets hold the rest, and whether the last poll refused every candidate it examined. The buckets come before the walk checks each issue on its own terms, so a held repository can show counts and still start nothing: read `lastPollRefusals`. TWO of the three success shapes report NO counts (`configured: false`, and `mode: "all-issues"`), because neither can be measured in label buckets and a fabricated zero would read as "nothing is eligible" rather than "not measured". Branch on `configured`, then on `mode`. Counts are bounded by one page: check `truncated` before reading any of them as a repository total, and by the DEFAULT tier semantics: when `customOrdering` is true the picker walks the repository's own queries and the buckets describe the default walk rather than what it would pick. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | This deployment has no author-App credentials, or this repository's author installation never completed. No counts exist, and none are fabricated. |
configured | true | yes | |
mode | "all-issues" | yes | The repository picks from ALL issues, so label-bucketed counts would not describe what it picks. No counts are reported, deliberately. |
configured | true | yes | |
mode | "labeled" | yes | |
diagnostics | object | yes | |
diagnostics.eligibilityLabel | string | yes | The configured label an issue must carry to be eligible. |
diagnostics.assignmentFilter | "exclude-assigned" | "bot-only" | yes | How the effective filter treats assignment. `exclude-assigned` is the steady state. `bot-only` is RECOVERY mode, and it is narrower than the name suggests: ONLY the bot's own stranded issues qualify, not issues with any assignee. |
diagnostics.totalEligibleLabeled | integer | yes | Open issues carrying the eligibility label. Bounded by one page — see `truncated`. |
diagnostics.wouldQualify | integer | yes | Pass the assignment filter and carry an active P0-P3 label. Counted BEFORE the walk checks each issue on its own terms (a pull request waiting for a merge, a failed run waiting for a retry, an answered issue waiting for an edit, a run already going), so it is NOT a count of issues the next poll will start. Read `lastPollRefusals` for what the last poll did with them. |
diagnostics.noPriorityLabel | integer | yes | Pass the assignment filter but carry no active P0-P3 label. Under the DEFAULT walk they are served by the unlabeled final tier after every priority tier, so ranked last rather than excluded. When `customOrdering` is true this count does NOT describe what the picker does: the bucketing is computed against the default tier semantics either way, while the picker walks the repository's own queries — an issue counted here may match one of those rows, and one counted in `wouldQualify` may match none. Read `customOrdering` first, and treat every count as default-walk semantics when it is true. |
diagnostics.staleRawTier | integer | yes | STRANDED until relabeled, and the one bucket that names its own remedy. These carry a reserved raw `P0`-`P3` spelling whose tier is mapped elsewhere, so they are excluded from the tier legs AND from the unlabeled tail. Always 0 under the identity taxonomy. |
diagnostics.assignmentExcluded | integer | yes | Excluded by the assignment filter, whether or not they carry a tier label — assignment gates the tier legs and the unlabeled tail alike. |
diagnostics.truncated | boolean | yes | TRUE means the page came back full, so every count above covers only the first page of issues by recency and NOT the repository. A truncated count presented as a total is the one way this read misleads. |
diagnostics.customOrdering | boolean | yes | TRUE when the repository configures an explicit `order_queries` array. The picker then walks exactly those rows with no implicit unlabeled tail. It is a CONFIG-LEVEL FACT rather than a classification bucket — the counts above are computed against the default tier semantics regardless — so when this is true, read them as 'what the default walk would do', not as a prediction of this repository's picker. |
diagnostics.lastPollRefusals | object | null | yes | The last poll's own verdict, when it refused EVERY candidate it examined: how many it examined, and how many each refusal refused. The same record `customer.repos.poller-status` reports on `held-page-exhausted`, read from where the walk wrote it, not re-derived. Non-null means the repository is held: a count above is not work about to start. `null` when the last poll did not refuse its whole page, predates the record, or the stored value could not be read. |
diagnostics.lastPollRefusals.examined | integer | yes | How many candidates the walk examined and refused. |
diagnostics.lastPollRefusals.reasons | object | yes | How many candidates each refusal refused, keyed by the refusal's code: a skip reason, or a finer code naming what releases it where the reason alone does not (for example `issue-answered-awaiting-blockers`, an issue waiting for work it depends on to land). |
diagnostics.orderingMatchesNoneSince | string | null | yes | When the runner found that this repository has issues ready for pickup and its configured ordering matched none of them, so no run starts (#27178). Read from `tenant_repos.empty_walk_ordering_finding_at`, where the runner records that verdict, not re-derived. Kept from the first time it was found, and cleared to `null` once a poll finds an issue the ordering matches. Non-null means pickup is stopped by the ordering itself: a count above is not work about to start. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The service failed while handling the request; nothing in the request was wrong. Retry. Where the operation takes an `Idempotency-Key`, retry with the same key: a request that may already have acted answers `outcome_unknown` rather than running twice. Answered by the audience's gate before the operation runs. |
| 502 | application/json | upstream_auth — An upstream service rejected this deployment's credential. (legacy `{ error }` body) |
| 503 | application/json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
GET /api/v1/audit
Operation customer.audit.list. List this tenant's authenticated API calls, newest first.
One row per authenticated call against this tenant, including the ones that did not succeed: a 403 for a key outside its scope, and a 500 for a call that failed after it was admitted. A 429 is never here — the only one this surface answers is the pre-authentication per-source bound, which refuses before a credential is read and so has no tenant to attribute a row to. `principal` says who: `api-key:<keyId>` when one of this tenant's credentials called, `user:<userId>` when a signed-in person exercised the same capability from the console — a key minted on the settings page is the same capability as a mint over this API, and both leave a row. A call with no tenant to attribute is not recorded, so an empty page does not mean nothing was tried. Paged by an opaque page token; `principal` narrows to one caller. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`.
Security: apiKey (audit:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
principal | query | string | no | Narrow the page to one credential's calls. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | |
items[].principal | string | yes | Who made the call, as an OPAQUE string the write side owns; do not branch on its prefix shape. A live row reads `api-key:<keyId>`, or `user:<userId>` for a signed-in person exercising the same capability from the console; a row whose subject has been erased reads a redaction marker instead. |
items[].operationId | string | null | yes | The operation the route serves, or `null` for a route the contract has not declared yet. |
items[].method | string | yes | The HTTP method, upper case. |
items[].path | string | yes | The request path without its query string. A segment may be a resource id, never prose. |
items[].scope | string | yes | The scope the operation required, `<family>:<read|write>`. |
items[].status | integer | yes | The HTTP status answered. |
items[].outcome | "ok" | "refused" | "failed" | yes | Derived from `status`: below 400 ok, 4xx refused, 5xx failed. |
items[].requestId | string | yes | The `X-Request-Id` the response carried. |
items[].createdAt | string | yes | |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/mcp
Operation customer.mcp.call. Speak MCP to this tenant's API.
The Model Context Protocol endpoint. An agent connects an MCP client here and drives the operations in this document as typed tools, with this key's own credential. THE SCOPE THIS REQUIRES IS THE ENVELOPE, NOT THE CONTENTS: holding it means a key may open the transport, and every tool call inside is still checked against the scope its own operation requires — so a key with only read scopes can open this and still cannot write through it. Requests are stateless: each one is served independently and nothing is held between them.
Security: apiKey (mcp:write)
Response
An empty object.
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/meta/operations
Operation customer.meta.operations. List the customer operation ids and the contract version this deployment serves.
Unauthenticated, and answered from the registry alone — no database, no credential, no tenant. What a client uses to learn whether it is behind: `descant version --check` diffs its bundled registry against this list and names the operations it cannot reach. IT COVERS THE CUSTOMER AUDIENCE ONLY, and says so in `audiences`. Those ids are what the OpenAPI document already publishes to anyone; the admin ids are not served here, because an unauthenticated caller is the person the help-listing ruling (#15338) protects and this would be the same disclosure on a wider channel. A caller holding an admin bearer is not that person and is served by its own operation. A client must restrict its own side of the diff to `audiences`, or every operation of an uncovered audience reads as one the deployment lacks. Carries no shapes or schemas — the OpenAPI document is the contract, this is its table of contents with a version stamp. Bounded by the per-source limiter every route under `/api/v1` pays.
Security: none
Response
| field | type | required | description |
|---|---|---|---|
contractVersion | string | yes | `CONTRACT_VERSION` of the deployed registry — the same value a binary built from it reports as `contract`. |
openapiVersion | string | yes | The OpenAPI version the document is written to. |
audiences | array of "customer" | "admin" | yes | WHICH AUDIENCES THIS LISTING COVERS, and therefore what a diff against it can conclude. This route answers `["customer"]`; an operation of an audience NOT named here is absent because it was not listed, never because the deployment does not serve it. A client must restrict its own side of the comparison to these audiences, or every unlisted id reads as one the deployment lacks. |
operations | array of object | yes | Every operation of the covered audiences that the deployed registry serves, in published order. A client diffs its own bundled registry — restricted to `audiences` — against this list; an id here and not there is one it cannot reach. |
operations[].id | string | yes | The operation id, exactly as the OpenAPI document and the CLI's `version --spec` spell it. |
operations[].method | string | yes | The HTTP method, lowercase. |
operations[].path | string | yes | The route template, with `{param}` placeholders. |
Errors
| status | body | meaning |
|---|---|---|
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/auth/device
Operation customer.auth.device.start. Begin a browser login and get the code the person will approve.
Unauthenticated, because it is what a caller with no credential uses to get one. Answers a secret `deviceCode` the client keeps, a short `userCode` a person reads, and the URL that shows the approval page. Nothing is minted here: the authorization sits pending until a signed-in person approves it, and it stops being redeemable after `expiresIn` seconds whether or not anyone does. Bounded by the per-IP limiter every route under `/api/v1` pays.
Security: none
Request body
| field | type | required | description |
|---|---|---|---|
clientName | string | no | What to call this client on the approval page. Display only, and shown as untrusted text. |
Response
| field | type | required | description |
|---|---|---|---|
deviceCode | string | yes | The secret half of the handshake: what the poll presents. Shown to nobody, stored only as a hash, and worth nothing once the key has been delivered against it. |
userCode | string | yes | The short code a person reads and confirms, as `XXXX-XXXX`. Matched case-insensitively with the dash optional. |
verificationUrl | string | yes | Where the person approves. Carries the user code already filled in, so the common path is a click rather than typing. |
expiresIn | integer | yes | Seconds until this authorization stops being redeemable. |
interval | integer | yes | Seconds the client should wait between polls. Polling faster answers `slow_down`. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/auth/device/token
Operation customer.auth.device.token. Poll a browser login, and collect the key once it is approved.
Answers 200 with a `status` discriminant in every case a caller will meet: `pending` while nobody has decided, `slow_down` when the client polls faster than the `interval` it was given, `approved` carrying the key EXACTLY ONCE, `denied` when the person refused, and `expired` when the window closed. `expired` is also the answer for a `deviceCode` this server never minted and for one whose key has already been collected — deliberately one indistinguishable outcome, so a caller cannot use this endpoint to learn whether a code exists. Never 4xx for `pending`: a poll that has not been decided yet is the normal state of this operation, not an error.
Security: none
Request body
| field | type | required | description |
|---|---|---|---|
deviceCode | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
status | "pending" | yes | |
interval | integer | yes | Seconds to wait before the next poll. |
status | "slow_down" | yes | |
interval | integer | yes | The client polled faster than `interval`. Not a refusal: wait this long and poll again. |
status | "approved" | yes | |
key | string | yes | The tenant API key (`dsc_…`), delivered once. A second poll answers `expired`. |
keyId | string | yes | The key's id, so a person can find this key in the dashboard's list and revoke it. |
tenantSlug | string | yes | Which organization the person approved for. The CLI prints it so a two-tenant operator can see which one they got. |
scopes | array of "runs:read" | "runs:write" | "repos:read" | "repos:write" | "invites:read" | "invites:write" | "audit:read" | "audit:write" | "keys:read" | "keys:write" | "mcp:read" | "mcp:write" | "settings:read" | "settings:write" | "billing:read" | "billing:write" | "team:read" | "team:write" | "analytics:read" | "analytics:write" | "notifications:read" | "notifications:write" | "sso:read" | "sso:write" | "security:read" | "security:write" | "capabilities:read" | "capabilities:write" | yes | What the approved key may exercise. Chosen by the person on the approval page, never by the client. |
expiresAt | string | null | yes | `null` never expires. |
status | "denied" | yes | |
status | "expired" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/keys
Operation customer.keys.list. List this tenant's API keys, newest first.
Every key the tenant has minted, active and revoked, newest first — a revoke is a soft one, so a revoked key stays in this list with `revokedAt` set. Never the secret: only the display prefix. Paged by an opaque page token, because a key holding `keys:write` mints without a person and nothing bounds the row count; follow `nextPageToken` until it is `null`. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`.
Security: apiKey (keys:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | |
items[].label | string | yes | |
items[].keyPrefix | string | yes | `dsc_` plus the first eight characters of the secret: how a key is named after it is minted. |
items[].scopes | array of "runs:read" | "runs:write" | "repos:read" | "repos:write" | "invites:read" | "invites:write" | "audit:read" | "audit:write" | "keys:read" | "keys:write" | "mcp:read" | "mcp:write" | "settings:read" | "settings:write" | "billing:read" | "billing:write" | "team:read" | "team:write" | "analytics:read" | "analytics:write" | "notifications:read" | "notifications:write" | "sso:read" | "sso:write" | "security:read" | "security:write" | "capabilities:read" | "capabilities:write" | yes | |
items[].expiresAt | string | null | yes | `null` never expires. |
items[].createdAt | string | yes | |
items[].lastUsedAt | string | null | yes | |
items[].revokedAt | string | null | yes | `null` while the key is active. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/keys
Operation customer.keys.create. Mint a key holding a subset of this key's scopes.
Mints a key for the same tenant, attributed to the same creating account as the minting key. The requested scopes must be a subset of the minting key's (a key cannot widen itself); `conflict` names the scope that is not held. The secret appears in this response and nowhere else.
Security: apiKey (keys:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
label | string | yes | |
scopes | array of "runs:read" | "runs:write" | "repos:read" | "repos:write" | "invites:read" | "invites:write" | "audit:read" | "audit:write" | "keys:read" | "keys:write" | "mcp:read" | "mcp:write" | "settings:read" | "settings:write" | "billing:read" | "billing:write" | "team:read" | "team:write" | "analytics:read" | "analytics:write" | "notifications:read" | "notifications:write" | "sso:read" | "sso:write" | "security:read" | "security:write" | "capabilities:read" | "capabilities:write" | yes | The scopes the new key holds. Must be a subset of the minting key's own — a key cannot widen itself. |
expiresAt | string | no | When the key stops authenticating. Omit for a key that does not expire. Must be in the future. |
Response
| field | type | required | description |
|---|---|---|---|
key | object | yes | |
key.id | string | yes | |
key.label | string | yes | |
key.keyPrefix | string | yes | `dsc_` plus the first eight characters of the secret: how a key is named after it is minted. |
key.scopes | array of "runs:read" | "runs:write" | "repos:read" | "repos:write" | "invites:read" | "invites:write" | "audit:read" | "audit:write" | "keys:read" | "keys:write" | "mcp:read" | "mcp:write" | "settings:read" | "settings:write" | "billing:read" | "billing:write" | "team:read" | "team:write" | "analytics:read" | "analytics:write" | "notifications:read" | "notifications:write" | "sso:read" | "sso:write" | "security:read" | "security:write" | "capabilities:read" | "capabilities:write" | yes | |
key.expiresAt | string | null | yes | `null` never expires. |
key.createdAt | string | yes | |
key.lastUsedAt | string | null | yes | |
key.revokedAt | string | null | yes | `null` while the key is active. |
key.rawKey | string | yes | The secret, shown exactly once. It is stored hashed and cannot be recovered; a caller that loses it mints a new key. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
DELETE /api/v1/keys/{keyId}
Operation customer.keys.revoke. Revoke one of this tenant's keys.
Soft-revokes: the row stays so the list can show it, and the key stops authenticating at once. A repeat reports `already-revoked`; a retry carrying the same `Idempotency-Key` within 24 hours answers the first revoke instead. A key id that belongs to another tenant, or to nothing, is one indistinguishable `not_found`. A key may revoke ITSELF; the call that does so is its last.
Security: apiKey (keys:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
keyId | path | string | yes | |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
key | object | yes | |
key.id | string | yes | |
key.outcome | "revoked" | "already-revoked" | yes | `already-revoked` on a repeat: the key was not active, so nothing changed. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
POST /api/v1/keys/{keyId}/rotate
Operation customer.keys.rotate. Replace a key with a successor; the old one lives on for a grace window.
Mints a successor holding the target's label and scopes, attributed to the minting key's creating account, and sets the target to stop authenticating at the end of the grace window (`graceSeconds`: default one hour, `0` at once) or at its own expiry, whichever is earlier — an expiry is never extended. A rotation is a mint, so the minting key must hold every scope the target does; `conflict` names the scope that is not held. A target already revoked is `conflict` too: it has nothing to rotate. So is a target holding a scope the server answering does not recognise — rotating it would drop that scope from the successor permanently, so it is refused rather than silently narrowed; this can happen briefly while a deployment is rolling, and a retry clears it. A key may rotate itself. The successor's secret appears in this response and nowhere else.
Security: apiKey (keys:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
keyId | path | string | yes | |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
graceSeconds | integer | no | How long the retired key keeps authenticating once the successor exists, in seconds. `0` retires it at once; the default is one hour; at most one day. |
expiresAt | string | no | When the SUCCESSOR stops authenticating. Omit for a successor that does not expire; the target's own expiry is not inherited. Must be in the future. |
Response
| field | type | required | description |
|---|---|---|---|
key | object | yes | |
key.id | string | yes | |
key.label | string | yes | |
key.keyPrefix | string | yes | `dsc_` plus the first eight characters of the secret: how a key is named after it is minted. |
key.scopes | array of "runs:read" | "runs:write" | "repos:read" | "repos:write" | "invites:read" | "invites:write" | "audit:read" | "audit:write" | "keys:read" | "keys:write" | "mcp:read" | "mcp:write" | "settings:read" | "settings:write" | "billing:read" | "billing:write" | "team:read" | "team:write" | "analytics:read" | "analytics:write" | "notifications:read" | "notifications:write" | "sso:read" | "sso:write" | "security:read" | "security:write" | "capabilities:read" | "capabilities:write" | yes | |
key.expiresAt | string | null | yes | `null` never expires. |
key.createdAt | string | yes | |
key.lastUsedAt | string | null | yes | |
key.revokedAt | string | null | yes | `null` while the key is active. |
key.rawKey | string | yes | The secret, shown exactly once. It is stored hashed and cannot be recovered; a caller that loses it mints a new key. |
retired | object | yes | |
retired.id | string | yes | |
retired.expiresAt | string | yes | When the retired key stops authenticating: the earlier of its own expiry and the end of the grace window. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/invites
Operation customer.invites.list. List the key's own account's invites, with the budget and referral status.
The invites belonging to the account that CREATED THIS KEY — not the tenant's current owner; see the family note, because which key you use decides whose budget you read. Metadata only: the invite code and its hash never appear here, only in the create response.
Security: apiKey (invites:read)
Response
| field | type | required | description |
|---|---|---|---|
invites | array of object | yes | METADATA ONLY — never the code or its hash. A list is re-readable, and a secret that can be re-read is not reveal-once. |
invites[].id | string | yes | The invite's id — what `customer.invites.revoke` takes. |
invites[].status | string | yes | Open by design, as the run state is: statuses are added as the invite lifecycle grows. |
invites[].createdAt | string | yes | |
invites[].redeemedAt | string | null | yes | `null` while the invite is unredeemed. |
budget | object | yes | |
budget.unlimited | true | yes | |
budget.remaining | null | yes | |
budget.unlimited | false | yes | |
budget.remaining | integer | yes | |
referralStatus | object | yes | |
referralStatus.invitesConverted | integer | yes | |
referralStatus.creditsEarnedMicros | string | yes | Micros as a decimal STRING, not a number: the value is exact and JSON numbers are not, which is the convention this API already uses for exact ids. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
POST /api/v1/invites
Operation customer.invites.create. Mint one invite, and reveal its code once.
REVEAL-ONCE: the `code` and `path` exist in this response and nowhere else. Surface the link and do not persist the code — no read can return it, and a lost code means minting another. The body carries no fields, so an ABSENT or empty body is the natural call and is read as `{}`; a body with an unknown field is refused rather than ignored. Spending the account's last slot answers `invite_limit_reached` — a well-formed request that a rule refused, which is why it is not the malformed-body code. It spends the CREATING account's budget, which is the binding that keeps one co-owner's key from spending another's.
Security: apiKey (invites:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
invite | object | yes | |
invite.id | string | yes | |
invite.code | string | yes | THE CODE, and this is the ONLY response that carries it. Surface it, hand it over, and do not persist it — nothing can return it again. |
invite.path | string | yes | The redemption path the code belongs to. |
budget | object | yes | The budget AFTER this mint, so a caller need not re-list to know what is left. |
budget.unlimited | true | yes | |
budget.remaining | null | yes | |
budget.unlimited | false | yes | |
budget.remaining | integer | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | invite_limit_reached — The tenant's invite limit is reached. / idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
DELETE /api/v1/invites/{id}
Operation customer.invites.revoke. Revoke one invite, reclaiming its budget slot.
Kills the link and returns the slot to the account's budget — the same primitive the dashboard's per-row Revoke uses, so the two surfaces cannot drift. A missing invite, an already-terminal one and ANOTHER ACCOUNT'S are one indistinguishable `not_found`: the underlying predicate is on the acting account, so nothing here discloses that an invite exists for somebody else. That also means a revoke is safe to repeat: the second call answers `not_found` rather than doing something new. A retry carrying the same `Idempotency-Key` within 24 hours answers the first revoke instead.
Security: apiKey (invites:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The invite id, as the list read returns it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
revoked | true | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
POST /api/v1/invites/send
Operation customer.invites.send. Mail one invite's link to an address.
Mails the link for one of the key's own account's still-pending invites to an address — the same send the dashboard's invite dialog makes, through the same service, so the two surfaces cannot drift. It takes the CODE, not the invite id: only the code's hash is stored, so the link cannot be rebuilt from an id, and a caller that no longer holds the code must revoke and mint another. `status: "sent"` means accepted for sending, never delivered: the mail goes out after the answer, best-effort, and it is the same answer for any well-formed address because nothing looks the address up. An address the service refuses is `invalid_body`, as is a malformed body. An unknown code, another account's, and one already redeemed or revoked are one indistinguishable `not_found`. Sends are metered per ACCOUNT on the same budget as the dashboard's send, charged before anything is checked, so a refused send spends it too; over it answers `rate_limited`. It acts on the account that CREATED the key, the binding every invite operation uses.
Security: apiKey (invites:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
code | string | yes | The invite code, exactly as `customer.invites.create` revealed it. An id will not do: only the code's hash is stored, so the link cannot be rebuilt from anything else. |
email | string | yes | The address to mail the link to. Nothing records it, and the invite stays one claimable link rather than one bound to this address. |
Response
| field | type | required | description |
|---|---|---|---|
status | "sent" | yes | Accepted for sending, never proof of delivery: the mail goes out after this answer, best-effort. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 409 | application/json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. (legacy `{ error }` body) |
| 422 | application/json | idempotency_key_reused — This Idempotency-Key was used with a different body. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/grooming/candidates
Operation customer.repos.grooming.candidates. Read the relationships Descant would propose for this repository's backlog.
PROPOSALS WITH THEIR EVIDENCE, not edges: nothing exists until `customer.repos.grooming.dispatch` lands it, and each candidate names the phrase in the tenant's own issue body that suggested it, because a relationship asserted with no reason cannot be reviewed. Served from the local copy of the backlog rather than a live walk — a proposal is a proposed WRITE against the copy the caller is looking at, so it must be derived from that same copy. `asOf`, `truncated` and `capped` say how current and how complete that copy was. WHEN THE COPY CANNOT SERVE, this answers `upstream_unavailable` and NEVER an empty list: an empty list is what a working detector returns over a backlog with no relationships, and the two must not be spelled the same.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
candidates | array of object | yes | PROPOSALS, not edges. Nothing exists until `dispatch` lands it. |
candidates[].id | string | yes | The candidate's own id, derived from its content — what a caller cites when acting on it. |
candidates[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
candidates[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
candidates[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
candidates[].evidence | array of object | yes | WHY the detector proposed this. `token` names the detector; `text` is the phrase from the tenant's own issue body that triggered it — free text, and the only free text this family publishes. It is the tenant's own backlog returned to that tenant, and without it a proposal is an assertion nobody can review. |
candidates[].evidence[].token | string | yes | |
candidates[].evidence[].text | string | yes | |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. A candidate is only as current as the copy it was derived from. |
truncated | boolean | yes | The copy cut the backlog off, so the proposal set is over a partial view. |
capped | boolean | yes | The detector stopped at its own ceiling, so there may be proposals it never reached. |
withheld | integer | yes | How many current proposals the caller's `answered` ids held back (`customer.repos.grooming.next-candidates`). Always 0 from the plain read. It is what tells "every proposal is answered" apart from "nothing to propose": both arrive as an empty list. |
nextPage | boolean | yes | Answering this page and reading again returns more: the page bound cut answerable proposals, or a proposal on this page is all that blocks another. NOT `capped`, which is also true when the detector stopped, where no further read ever reaches more. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | upstream_unavailable — A service this operation depends on did not answer. |
POST /api/v1/repos/{id}/grooming/candidates
Operation customer.repos.grooming.next-candidates. Read the next proposals for this repository's backlog, past the ones you have answered.
THE SAME READ AS `customer.repos.grooming.candidates`, carrying the ids you have already answered. The proposal set is bounded, so a caller that only filtered its answers out of the plain read would get the same first page for ever. Here the answered ids are held back BEFORE the bound, and the page is proposals still to answer. IT IS A POST BECAUSE THOUSANDS OF IDS DO NOT FIT A URL, NOT BECAUSE ANYTHING CHANGES: it writes nothing, holds nothing between calls, and answers the same body over the same copy with the same page. `withheld` counts the current proposals your answers held back, and `nextPage` says whether a further read returns more. Served from the local copy and refused as `upstream_unavailable`, never an empty list, on the same terms as the plain read.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Request body
| field | type | required | description |
|---|---|---|---|
answered | array of string | yes | The candidate ids (`id` on a candidate) this caller has already answered. They are held back BEFORE the page bound, so the page is proposals still to answer. An answer spends nothing: a proposal it was blocking can be offered. Keep them between calls; send the newest when you have more than the bound. |
pending | array of string | no | Relationships this caller has written that the local copy may not carry yet, as candidate ids (`kind|from|to`). They are judged as encoded, so the page is not filled with proposals they would make unanswerable. An id naming no open issue, or a kind the format does not have, is ignored. |
Response
| field | type | required | description |
|---|---|---|---|
candidates | array of object | yes | PROPOSALS, not edges. Nothing exists until `dispatch` lands it. |
candidates[].id | string | yes | The candidate's own id, derived from its content — what a caller cites when acting on it. |
candidates[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
candidates[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
candidates[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
candidates[].evidence | array of object | yes | WHY the detector proposed this. `token` names the detector; `text` is the phrase from the tenant's own issue body that triggered it — free text, and the only free text this family publishes. It is the tenant's own backlog returned to that tenant, and without it a proposal is an assertion nobody can review. |
candidates[].evidence[].token | string | yes | |
candidates[].evidence[].text | string | yes | |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. A candidate is only as current as the copy it was derived from. |
truncated | boolean | yes | The copy cut the backlog off, so the proposal set is over a partial view. |
capped | boolean | yes | The detector stopped at its own ceiling, so there may be proposals it never reached. |
withheld | integer | yes | How many current proposals the caller's `answered` ids held back (`customer.repos.grooming.next-candidates`). Always 0 from the plain read. It is what tells "every proposal is answered" apart from "nothing to propose": both arrive as an empty list. |
nextPage | boolean | yes | Answering this page and reading again returns more: the page bound cut answerable proposals, or a proposal on this page is all that blocks another. NOT `capped`, which is also true when the detector stopped, where no further read ever reaches more. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | upstream_unavailable — A service this operation depends on did not answer. |
GET /api/v1/repos/{id}/grooming/document
Operation customer.repos.grooming.document. Read the relationship document this repository's issues currently encode.
The issues and the edges together, as the workspace renders them — what IS, where the candidates read is what COULD be. `cycleGraphIncomplete` is non-null when the cycle check could not see the whole graph: do not read 'no cycles' from an incomplete one. `encodingRefused` names issues whose relationships could not be written back into their bodies, so their edges are readable here and NOT persisted upstream — a distinction that decides whether another reader will see them. Mirror-served and typed-refusal on the same terms as the candidates read; see it.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
document | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
document.issues | array of object | yes | |
document.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.issues[].title | string | yes | |
document.issues[].state | "open" | "closed" | yes | |
document.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
document.issues[].url | string | no | |
document.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as that ref's value; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
document.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
document.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
document.issues[].order.ready | boolean | yes | |
document.issues[].order.holds | array of object | yes | |
document.issues[].order.holds[].family | "graph" | "tracker" | yes | |
document.issues[].order.holds[].reason | string | yes | |
document.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | "suppressed" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict carries none. |
document.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
document.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
document.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
document.issues[].unit | array of string | yes | |
document.issues[].lead | string | null | yes | |
document.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
document.issues[].promotedBy | array of string | yes | |
document.edges | array of object | yes | |
document.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
document.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
document.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked, as the document keys that issue: the bare issue number (`901`) for an issue in this repository, `owner/repo#N` for another repository's. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | upstream_unavailable — A service this operation depends on did not answer. |
POST /api/v1/repos/{id}/grooming/dispatch
Operation customer.repos.grooming.dispatch. Land one relationship edit in the issue bodies, fenced on what the caller read.
ONE mutation per call — create, delete, retype or flip — written into the GitHub ISSUE BODY through this repository's App installation. It does NOT seed a run and never reaches the runner, so no capacity answer applies. FENCED ON `expected`: issue ref to the `updatedAt` you hydrated with. An issue that moved since then answers `outcome: "conflict"` carrying the authoritative document, so a caller re-derives rather than overwriting an edit it never saw. WHEN AN ANSWER CARRIES A DOCUMENT IT IS THE WHOLE ONE, never a patch — `applied`, `unchanged` and `conflict` each carry it, and `unchanged` carries it precisely because 'nothing to do' is often said when your copy is stale and somebody else already created the edge. A `rejected` carries NO document: it carries a reason, and the `landed` flag if the write reached the issue before the failure. `rejected` means the write was refused upstream and nothing changed there — WITH ONE EXCEPTION that inverts it: a `rejected` carrying `landed: true` means the write DID land and only the local copy is behind, so drop the document and the `updatedAt` tokens you hold and re-read before your next edit. WHICH FIELDS GO WITH WHICH `op`, because the body is flat rather than a union: `create` takes `kind`, `from` and `to`; `delete` and `flip` take `edgeId`; `retype` takes `edgeId` and `nextKind`. `mutationId` is yours to mint and is required on every op. A field belonging to a different op is REFUSED rather than ignored — a body carrying both describes two edits and applying either is a guess — and a missing one answers `invalid_body` naming the field.
Security: apiKey (repos:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
op | "create" | "delete" | "retype" | "flip" | yes | WHICH edit. The fields that go with it are listed in this operation's description and checked by the route. |
mutationId | string | yes | YOURS TO MINT, required on every op, and echoed in this deployment's logs so one edit can be traced end to end. It is NOT replay protection: nothing here deduplicates on it, and resending the same id performs the edit again. For a safe retry send an `Idempotency-Key` header, which answers the first response; `expected` also fences it — a stale stamp answers `conflict` rather than writing twice. |
kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | no | `create` only: the relationship to add. `together-with` is absent deliberately — the writer cannot splice it, so offering it would advertise a create that always fails. |
from | string | no | `create` only. |
to | string | no | `create` only. |
edgeId | string | no | `delete`, `retype` and `flip`: the relationship being changed, as the document returns it. |
nextKind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | no | `retype` only: what the relationship becomes. Not `together-with`, for the reason `kind` gives — and a retype OUT of an existing `together-with` is refused by the writer too, which no enum here can say. |
expected | object | yes | THE CONFLICT FENCE: issue ref to the `updatedAt` you hydrated with. An issue that moved upstream since then answers `conflict` with the authoritative document rather than overwriting an edit you never saw. On the CLI, `--expected <ref>=<updatedAt>`, repeated. |
Response
| field | type | required | description |
|---|---|---|---|
outcome | "applied" | yes | |
document | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
document.issues | array of object | yes | |
document.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.issues[].title | string | yes | |
document.issues[].state | "open" | "closed" | yes | |
document.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
document.issues[].url | string | no | |
document.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as that ref's value; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
document.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
document.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
document.issues[].order.ready | boolean | yes | |
document.issues[].order.holds | array of object | yes | |
document.issues[].order.holds[].family | "graph" | "tracker" | yes | |
document.issues[].order.holds[].reason | string | yes | |
document.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | "suppressed" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict carries none. |
document.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
document.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
document.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
document.issues[].unit | array of string | yes | |
document.issues[].lead | string | null | yes | |
document.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
document.issues[].promotedBy | array of string | yes | |
document.edges | array of object | yes | |
document.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
document.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
document.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked, as the document keys that issue: the bare issue number (`901`) for an issue in this repository, `owner/repo#N` for another repository's. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
outcome | "unchanged" | yes | |
document | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
document.issues | array of object | yes | |
document.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.issues[].title | string | yes | |
document.issues[].state | "open" | "closed" | yes | |
document.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
document.issues[].url | string | no | |
document.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as that ref's value; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
document.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
document.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
document.issues[].order.ready | boolean | yes | |
document.issues[].order.holds | array of object | yes | |
document.issues[].order.holds[].family | "graph" | "tracker" | yes | |
document.issues[].order.holds[].reason | string | yes | |
document.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | "suppressed" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict carries none. |
document.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
document.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
document.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
document.issues[].unit | array of string | yes | |
document.issues[].lead | string | null | yes | |
document.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
document.issues[].promotedBy | array of string | yes | |
document.edges | array of object | yes | |
document.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
document.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
document.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked, as the document keys that issue: the bare issue number (`901`) for an issue in this repository, `owner/repo#N` for another repository's. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
outcome | "rejected" | yes | |
reason | string | yes | |
landed | true | no | PRESENT ONLY WHEN THE WRITE ACTUALLY LANDED and the local copy does not yet hold it. DROP the document and the `updatedAt` tokens you are holding and re-read: the next edit judged on them would splice a stale body over the one just written. Absent on an ordinary refusal, where nothing changed. |
outcome | "conflict" | yes | |
upstream | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
upstream.issues | array of object | yes | |
upstream.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
upstream.issues[].title | string | yes | |
upstream.issues[].state | "open" | "closed" | yes | |
upstream.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
upstream.issues[].url | string | no | |
upstream.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as that ref's value; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
upstream.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
upstream.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
upstream.issues[].order.ready | boolean | yes | |
upstream.issues[].order.holds | array of object | yes | |
upstream.issues[].order.holds[].family | "graph" | "tracker" | yes | |
upstream.issues[].order.holds[].reason | string | yes | |
upstream.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | "suppressed" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict carries none. |
upstream.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
upstream.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
upstream.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
upstream.issues[].unit | array of string | yes | |
upstream.issues[].lead | string | null | yes | |
upstream.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
upstream.issues[].promotedBy | array of string | yes | |
upstream.edges | array of object | yes | |
upstream.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
upstream.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
upstream.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
upstream.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked, as the document keys that issue: the bare issue number (`901`) for an issue in this repository, `owner/repo#N` for another repository's. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
POST /api/v1/repos/{id}/order-preview
Operation customer.repos.order-preview. Preview the whole ordering walk under an unsaved draft.
What this repository's backlog WOULD be worked in, under a draft you have not saved — the point being to see the draft's effect before committing to it. THE BODY IS A PARTIAL OVERLAY and omission is meaningful: leaving a field out keeps the SAVED value, and `orderQueries` additionally distinguishes `null` (clear them) from absent (keep them). Sending `null` when you meant 'no opinion' previews a backlog with no queries. `complete: false` means `rows` is a prefix rather than the whole backlog. `configured: false` is a success meaning the local copy of the issues is not ready to read yet — the settings stay writable. An ordering query the copy cannot evaluate is `invalid_body` (it came from your draft) or `conflict` (it is saved), naming the term. It WRITES NOTHING: a preview computes and discards.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Request body
| field | type | required | description |
|---|---|---|---|
eligibilityMode | "labeled" | "all-issues" | no | OMIT to keep the saved mode. `labeled` requires the eligibility label; `all-issues` considers every open issue. The same closed set `customer.repos.config` takes. |
eligibilityLabel | string | no | OMIT to keep the saved label. Validated against the stored label exactly as the save path validates it. |
orderStrategy | "oldest-first" | "newest-first" | no | OMIT to keep the saved strategy. The direction the picker walks a tier — the same closed set `customer.repos.config` takes. |
excludeAssigned | boolean | no | OMIT to keep the saved value. `false` MEANS false — no presence marker is needed here, because unlike an HTML checkbox a JSON key that is present is present. |
labelMap | object | null | no | The label map, Descant's label key to the repository's own label. Keys: `P0`, `P1`, `P2`, `P3`, `type:bug`, `type:feature`, `type:chore`, `type:unassessed`. On the CLI, `--label-map <KEY>=<label>`, repeated. OMIT to keep the saved map. Send `null` — or an empty map — to preview with NO mapping at all: `null` is the spelling that survives the generated CLI, where a repeatable flag given zero times is indistinguishable from omitting the field. A key with an empty label (`P1=`) leaves THAT key unmapped, as the settings form reads a blank control. An unknown key is `invalid_body`. |
orderQueries | array of string | null | no | THREE STATES, and the one field where absent and `null` differ. OMIT to keep the saved queries; send `null` to CLEAR them and preview with none; send an array to replace them. A caller that sends `null` meaning "nothing to say about queries" will preview a backlog with no queries at all. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
reason | "mirror-importing" | yes | The local copy of this repository's issues is not ready to read: it was never imported, its import has not finished, or nothing is maintaining it. NOT an error: the ordering settings are still writable. |
configured | true | yes | |
rows | array of object | yes | The walk, in order. |
rows[].position | integer | yes | Where this issue lands in the walk, 1-based. |
rows[].number | integer | yes | |
rows[].title | string | yes | |
rows[].url | string | yes | |
rows[].labels | array of string | yes | |
rows[].createdAt | string | yes | |
rows[].matchedRule | object | null | yes | `null` when the issue is included by the walk's own fallback rather than by a rule. |
rows[].matchedRule.kind | "query" | yes | |
rows[].matchedRule.index | integer | yes | Which `orderQueries` entry matched, 0-based — the row to edit if this attribution is wrong. |
rows[].matchedRule.query | string | yes | The query text as the walk ran it. |
rows[].matchedRule.kind | "tier" | yes | |
rows[].matchedRule.index | integer | yes | |
rows[].matchedRule.label | string | yes | The RESOLVED spelling the walk queried: the mapped label on a repository with a label map, the raw `P0`..`P3` otherwise. |
rows[].matchedRule.kind | "unlabeled-tail" | yes | |
complete | boolean | yes | TRUE only when `rows` IS the whole backlog — which requires PROVING exhaustion, not merely finding no fault. False when `rows` reached `limit` (the walk stops there and drops the rest silently, so exhaustion is unprovable), or when the copy that answered is not proven current (`mirroredCopy` is present). Treat `rows` as a prefix whenever it is false. A backlog of exactly `limit` reports false, which is the safe direction. |
limit | integer | yes | The ceiling the walk stopped at. |
orderStrategy | string | yes | The strategy actually used, after the overlay was applied — echo it rather than assuming your draft took effect. |
mirroredAsOf | string | yes | When the local copy this ordering was derived from was last known current — or, with `mirroredCopy: unconfirmed`, the latest instant it can be dated to. |
mirroredCopy | "behind" | "unconfirmed" | no | Present only when the copy that answered was NOT current: `behind` means it is known to trail the provider, `unconfirmed` means its position could not be confirmed. Absent alongside `mirroredAsOf` means the copy was current. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/repos/{id}/ordering-preview
Operation customer.repos.ordering-preview. Preview what one ordering query matches.
The per-query half of the pair: what a single search actually matches, as a count plus a sample. An author checking a query they are writing wants this; an author checking the RESULT wants `customer.repos.order-preview`. The query is evaluated against the local copy of the repository's issues with the product's own scoping applied, exactly as pickup evaluates it. `items` is a sample and `totalCount` is the count; do not read the length of one as the other. `unsupported-term` names a term the local copy cannot evaluate; `mirror-importing` means the copy is not ready yet. It WRITES NOTHING.
Security: apiKey (repos:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Request body
| field | type | required | description |
|---|---|---|---|
query | string | yes | The one search to preview, in the provider's own query syntax. |
Response
| field | type | required | description |
|---|---|---|---|
outcome | "matched" | yes | |
totalCount | integer | yes | How many issues the query matches — the number the author is usually checking. |
items | array of object | yes | A SAMPLE, not the whole match set. `totalCount` is the count; this is what it looks like. |
items[].number | integer | yes | |
items[].title | string | yes | |
items[].labels | array of string | yes | |
items[].createdAt | string | yes | |
executedQuery | string | yes | The query as pickup evaluates it, after the product's own scoping was folded in. Compare it against what you wrote when a result surprises you. |
mirroredCopy | "behind" | "unconfirmed" | no | Present only when the local copy that answered is not proven current, as on the whole-walk preview. |
outcome | "unsupported-term" | yes | |
term | string | yes | The term the local copy cannot evaluate. Pickup refuses an ordering that uses it, so remove or replace it. |
outcome | "mirror-importing" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/settings/engine-tier
Operation customer.settings.engine-tier-get. Read this tenant's engine tier.
The engine tier recorded for the tenant the key belongs to — there is no way to name another. Answers in the same vocabulary as setting it: the tier's id, `null` when none is chosen and the deployment default applies, or `retired` for an organization on an earlier engine that can no longer be selected.
Security: apiKey (settings:read)
Response
| field | type | required | description |
|---|---|---|---|
sopranoTier | "soprano-7" | "retired" | null | yes | The tier recorded for this tenant. `null` means no tier is selected, so the deployment default applies. `retired` means the organization stays on an earlier engine that can no longer be selected. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/settings/engine-tier
Operation customer.settings.engine-tier-set. Set this tenant's engine tier.
Changes which model pair runs this tenant's pipeline, for the tenant the key belongs to — there is no way to name another. Answers what the database RECORDED plus whether anything was written: `changed: false` is a successful no-op, which is what re-sending the tier you are already on is. An organization on an earlier engine reads back as `retired`, and stays on it until it chooses a tier. On a zero-data-retention deployment a tier that is not ZDR-eligible is refused as a `conflict` BEFORE the write, so the caller finds out now rather than through runs that fail later.
Security: apiKey (settings:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
sopranoTier | "soprano-7" | null | yes | The tier to run on, or null to clear the selection back to the deployment default. |
Response
| field | type | required | description |
|---|---|---|---|
sopranoTier | "soprano-7" | "retired" | null | yes | The tier now recorded for this tenant, read back rather than echoed from the request. `null` means no tier is selected, so the deployment default applies. `retired` means the organization stays on an earlier engine that can no longer be selected. |
changed | boolean | yes | Whether this call wrote a row. `false` is a SUCCESSFUL no-op — a keep-current save deliberately writes nothing, which is what keeps two concurrent saves from racing. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/billing/credit-packs
Operation customer.billing.credit-pack-list. List the credit packs on sale and the saved payment method a purchase would charge.
What a purchase must confirm, for the tenant the key belongs to: every fixed pack with the terms it is sold on, and the saved default payment method with the id a purchase sends back. Only a key created by a current OWNER may read it; a closed account is `conflict`.
Security: apiKey (billing:read)
Response
| field | type | required | description |
|---|---|---|---|
packs | array of object | yes | Every fixed pack on sale, on the terms a purchase must send back as `expectedTerms`. |
packs[].packKey | "25k" | "50k" | "100k" | yes | |
packs[].terms | object | yes | |
packs[].terms.amountCents | integer | yes | What the pack costs, in the currency's minor unit. |
packs[].terms.currency | string | yes | Lowercase ISO 4217, e.g. `usd`. |
packs[].terms.credits | integer | yes | How many credits the pack buys. |
savedPaymentMethod | object | yes | The default payment method a purchase would charge. `none`: nothing is saved; save one on the billing page. `unavailable`: it could not be read just now; read again before buying. |
savedPaymentMethod.status | "saved" | yes | |
savedPaymentMethod.id | string | yes | Send this as `paymentMethodId` to buy with it. |
savedPaymentMethod.label | string | yes | How the billing page names it, e.g. a card brand and its last four digits. |
savedPaymentMethod.status | "none" | yes | |
savedPaymentMethod.status | "unavailable" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/billing/credit-packs
Operation customer.billing.credit-pack-buy. Buy a credit pack with the saved payment method.
Charges one fixed credit pack to the tenant's saved default payment method, the same purchase as the billing page's one-click buy, for the tenant the key belongs to. Only a key created by a current OWNER of the tenant may buy; a closed account and a tenant with no saved payment method are refused as `conflict` before any charge (there is no card entry on this surface: save a method on the billing page first). THE SAME CONFIRMATION AS THE PAGE: send the saved method's `id` and the pack's `terms` from `customer.billing.credit-pack-list`. The purchase charges only if the saved default is still that method and the pack is still on those terms; otherwise it is `conflict` and nothing is charged. An `Idempotency-Key` header is REQUIRED, and a request without one is refused before anything is charged. The same key and body within 24 hours answers the first result instead of charging again. When the payment could not be started (`upstream_unavailable`), nothing was charged and the key is given back, so a retry under it runs. When the charge request itself failed (`outcome_unknown`), money may have moved: every retry under that key answers the same for 24 hours, and after that a request under it is a NEW purchase, so read the billing history before buying again under any key. A card's own refusal is an answer, not an error: `outcome: declined` or `authentication-required`.
Security: apiKey (billing:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | yes | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
packKey | "25k" | "50k" | "100k" | yes | Which fixed pack to buy: `25k`, `50k` or `100k` credits. |
paymentMethodId | string | yes | The saved method's `id` from `billing credit-pack list`. A different saved default now is `conflict`, and nothing is charged. |
expectedTerms | object | yes | The pack's `terms` from `billing credit-pack list`. Terms that differ from what is on sale now are `conflict`, and nothing is charged. |
expectedTerms.amountCents | integer | yes | What the pack costs, in the currency's minor unit. |
expectedTerms.currency | string | yes | Lowercase ISO 4217, e.g. `usd`. |
expectedTerms.credits | integer | yes | How many credits the pack buys. |
Response
| field | type | required | description |
|---|---|---|---|
packKey | "25k" | "50k" | "100k" | yes | |
outcome | "paid" | "processing" | "declined" | "authentication-required" | yes | `paid`: the charge succeeded and the credits arrive a moment later. `processing`: the payment is still being processed; the credits arrive when it settles. `declined`: the card refused the charge and nothing was charged. `authentication-required`: the card needs the holder to confirm it, which this surface cannot do; buy from the billing page instead. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | upstream_unavailable — A service this operation depends on did not answer. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/billing/auto-reload
Operation customer.billing.auto-reload-get. Read this tenant's auto-reload settings.
The billing page's auto-reload settings for the tenant the key belongs to: whether it is on, the threshold and the target balance a reload brings the account back up to, in credits (`null` uses the default, which is named beside it), and whether a saved payment method is on file. Only a key created by a current OWNER may read it. A closed account, and an account whose billing is not set up, are `conflict`.
Security: apiKey (billing:read)
Response
| field | type | required | description |
|---|---|---|---|
enabled | boolean | yes | Whether auto-reload is on. |
thresholdCredits | integer | null | yes | The balance, in credits, at or below which a reload is bought; `null` uses `defaultThresholdCredits`. |
targetCredits | integer | null | yes | The balance, in credits, a reload brings the account back up to; `null` uses `defaultTargetCredits`. One reload buys the difference between it and the balance at that moment: at least 1,000 credits, at most 1,000,000. |
defaultThresholdCredits | integer | yes | |
defaultTargetCredits | integer | yes | |
paymentMethod | "has-card" | "no-card" | "unknown" | yes | Whether a saved payment method is on file for reloads to charge. `unknown`: it could not be read just now. Save one on the billing page; there is no card entry on this surface. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/billing/auto-reload
Operation customer.billing.auto-reload-set. Turn auto-reload on or off, and set its threshold and target balance.
The billing page's auto-reload form, for the tenant the key belongs to, through the same save. Only a key created by a current owner or admin (the roles holding `billing.manage`) may set it; a closed account and one whose billing is not set up are `conflict`. Turning it OFF keeps the stored threshold and target. Turning it ON keeps the stored value of a field left out (`null` asks for the default) and validates both as the page does (at least 1,000 credits each; the target above the threshold and at most 1,000,000 credits above it, the most one reload buys) and answers `invalid_body` naming the field. Leaving `enabled` out keeps the switch as it is and saves the values sent, each checked as the page checks it; the pair is checked as for turning it on only while auto-reload stays on, since an off setting charges nothing and is checked again when turned on. A SAVE THAT LEAVES IT ON CAN CHARGE THE SAVED PAYMENT METHOD RIGHT AWAY, whether it turns it on or keeps it on with `enabled` left out: when the balance is already at or below the threshold, or the account is paused for want of credits, the save buys ONE reload now, as the billing page's save does. `reload` says what happened. A second save cannot start a second reload while one is in progress, but once its credits arrive, a save sent while the balance is still at or below the threshold buys another. Read `reload` and the balance before sending the save again.
Security: apiKey (billing:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
enabled | boolean | no | Turn auto-reload on (`true`) or off (`false`). Omitted keeps the switch as it is and saves the threshold and target you send, as the billing page's save-amounts form does: each value sent meets the page's rules, and when auto-reload stays on the saved pair is checked as for turning it on. While it stays off nothing is charged, and the pair is checked when it is next turned on. A body naming nothing is `invalid_body`. |
thresholdCredits | integer | null | no | Whole credits; omitted keeps the stored value, and `null` uses the default. Ignored when `enabled` is false. |
targetCredits | integer | null | no | Whole credits, more than the threshold and at most 1,000,000 above it; omitted keeps the stored value, and `null` uses the default. Ignored when `enabled` is false. |
amountCredits | integer | null | no | DEPRECATED, accepted for one release: the old fixed reload amount. Read as a target of the `thresholdCredits` sent in the same request plus this amount (a number needs `thresholdCredits` beside it), and `null` as the default target. Send `targetCredits` instead; sending both is `invalid_body`. |
Response
| field | type | required | description |
|---|---|---|---|
enabled | boolean | yes | |
reload | "off" | "started" | "not-needed" | "no-payment-method" | "pending" | "unknown" | yes | What turning it on did right away. `off`: the save turned auto-reload off; nothing was bought. `started`: the balance was at or below the threshold, so ONE reload was charged to the saved payment method; the credits arrive a moment later. `not-needed`: the balance is above the threshold. `no-payment-method`: there is no saved payment method to charge. `pending`: a reload is already in progress. `unknown`: whether a reload started could not be confirmed; check the balance before saving again. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/settings/repo-defaults
Operation customer.settings.repo-defaults-get. Read this tenant's repository defaults.
The repository defaults page, for the tenant the key belongs to: whether newly connected repositories start with auto-merge on and whether they start paused (both seeds for repositories connected later, not changes to existing ones), and what happens to a pull request a run abandons (applied to every repository now). A tenant that never set them reads back the defaults.
Security: apiKey (settings:read)
Response
| field | type | required | description |
|---|---|---|---|
autoMergeEnabled | boolean | yes | Whether a repository this organization connects from now on starts with auto-merge on. A SEED: it does not change repositories already connected, whose own setting is authoritative. |
paused | boolean | yes | Whether a repository this organization connects from now on starts paused. A SEED, like `autoMergeEnabled`: it does not change repositories already connected. |
onAbandonedPr | "comment-and-leave-open" | "close-with-comment" | yes | What Descant does with a pull request a run abandons: `comment-and-leave-open` or `close-with-comment`. NOT a seed: it applies to every repository of this organization, now. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/settings/repo-defaults
Operation customer.settings.repo-defaults-set. Set this tenant's repository defaults.
Saves the repository defaults page, for the tenant the key belongs to: whether newly connected repositories start with auto-merge on and whether they start paused (seeds for repositories connected later; repositories already connected keep their own settings), and what happens to a pull request a run abandons (applied to every repository now). Both toggles are required. Leave out `onAbandonedPr` to keep the stored value. Answers the defaults as recorded, read back after the save.
Security: apiKey (settings:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
autoMergeEnabled | boolean | yes | Whether a repository this organization connects from now on starts with auto-merge on. A SEED: it does not change repositories already connected, whose own setting is authoritative. |
paused | boolean | yes | Whether a repository this organization connects from now on starts paused. A SEED, like `autoMergeEnabled`: it does not change repositories already connected. |
onAbandonedPr | "comment-and-leave-open" | "close-with-comment" | no | What Descant does with a pull request a run abandons: `comment-and-leave-open` or `close-with-comment`. Applies to every repository of this organization, now. Leave it out to keep the stored value. |
Response
| field | type | required | description |
|---|---|---|---|
autoMergeEnabled | boolean | yes | Whether a repository this organization connects from now on starts with auto-merge on. A SEED: it does not change repositories already connected, whose own setting is authoritative. |
paused | boolean | yes | Whether a repository this organization connects from now on starts paused. A SEED, like `autoMergeEnabled`: it does not change repositories already connected. |
onAbandonedPr | "comment-and-leave-open" | "close-with-comment" | yes | What Descant does with a pull request a run abandons: `comment-and-leave-open` or `close-with-comment`. NOT a seed: it applies to every repository of this organization, now. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/me
Operation customer.settings.profile-get. Read the profile of the person who created this key.
The profile page's identity for the person who created the key: display name, email address, whether it is verified and when, avatar image, and the sign-in methods connected to the account. The person is read again on every call, so a key whose creator has left the organization, or that records no creator, is refused. A closed account is `conflict`. Browser sessions are not included: managing them is a dashboard action.
Security: apiKey (settings:read)
Response
| field | type | required | description |
|---|---|---|---|
name | string | null | yes | The display name on the account; `null` when none is set. |
email | string | yes | The account's email address. |
emailVerified | boolean | yes | Whether the address has been verified. |
emailVerifiedAt | string | null | yes | When the address was verified, as an ISO instant; `null` while it is unverified. |
image | string | null | yes | The account's avatar image URL; `null` when none is set. |
linkedProviders | array of string | yes | The sign-in methods connected to the account, by provider id, sorted. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/settings/usage
Operation customer.settings.usage-get. Read this organization's concurrency usage and the pool it shares.
The Overview's active-runs numbers for the tenant the key belongs to: this organization's running, waiting and parked runs, and the account-wide pool it draws from — the account's cap, the runs holding a slot across the account (other organizations only as one total, never by name) and the free slots. Set the cap with `customer.settings.usage-set`.
Security: apiKey (settings:read)
Response
| field | type | required | description |
|---|---|---|---|
cap | integer | yes | The account's maximum concurrent runs, shared by every organization the account owns. Not this organization's own limit. |
running | integer | yes | This organization's runs holding a slot right now. |
accountRunning | integer | yes | Runs holding a slot across the whole account, this organization's included. `accountRunning - running` is the other organizations' share. |
free | integer | yes | Slots free in the shared pool: the cap minus `accountRunning`, never below zero. |
waiting | integer | yes | This organization's runs resting behind a dispatch gate. The sum of `waitingByReason`. |
waitingByReason | object | yes | `waiting`, split by which ceiling rested each run. |
waitingByReason.slot | integer | yes | Waiting for a slot under the account's cap, which the account can raise. |
waitingByReason.vendorBudget | integer | yes | Waiting on Descant's own estate-wide ceiling, which no account setting changes. |
parked | integer | yes | This organization's runs parked with their slot given back. Counted in neither `running` nor `waiting`, and never part of the arithmetic for `free`. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/settings/usage
Operation customer.settings.usage-set. Set the account's maximum concurrent runs.
The usage page's concurrency limit, through the same save. THE CAP IS THE ACCOUNT'S, NOT THIS ORGANIZATION'S: it bounds the runs of every organization the key's creator owns, together, so raising or lowering it here changes the pool all of them share. Only a key whose creator owns this organization may set it. `maxConcurrentRuns` is a whole number within the page's bounds; outside them is `invalid_body`. A lower cap does not stop runs already holding a slot: new runs wait until the pool has room. Read the pool with `customer.settings.usage-get`.
Security: apiKey (settings:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
maxConcurrentRuns | integer | yes | The account's maximum concurrent runs, 1 to 100, shared by every organization the account owns. |
Response
| field | type | required | description |
|---|---|---|---|
cap | integer | yes | The account's maximum concurrent runs now in force, shared by every organization it owns. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/team/members
Operation customer.team.members-list. List this tenant's members.
Every account that has joined the organization the key belongs to, owners first, then oldest membership first: each member's account id, sign-in address, role and when they joined. Pending invitations are not members and are not listed. Paged by an opaque page token; follow `nextPageToken` until it is `null`. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`. Pending invitations are `customer.team.invitations-list`.
Security: apiKey (team:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | The member's account id. |
items[].email | string | yes | The address the member signs in with. |
items[].role | "owner" | "admin" | "billing" | "member" | yes | `owner`, `admin`, `billing` or `member`. |
items[].joinedAt | string | yes | When the account joined this organization. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/tenants/me
Operation customer.team.organization-get. Read the organization this key belongs to.
The organization the key belongs to — its id, short name and display name — and the role the person who created the key holds in it now. Only this organization: the creator's other memberships are never listed. Read-only.
Security: apiKey (team:read)
Response
| field | type | required | description |
|---|---|---|---|
organization | object | yes | |
organization.id | string | yes | The organization's id. |
organization.slug | string | yes | The organization's short name, as its dashboard addresses show it. |
organization.displayName | string | yes | The organization's name, as the dashboard shows it. |
creatorRole | "owner" | "admin" | "billing" | "member" | yes | The role the person who created this key holds in this organization now: `owner`, `admin`, `billing` or `member`. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PATCH /api/v1/tenants/me
Operation customer.team.organization-rename. Rename the organization this key belongs to.
Renames the organization the key belongs to, as the team page's rename does, through the same writer: the name is trimmed and must pass the rule a new organization's name does, and the short name does not change. Answers the organization as `customer.team.organization-get` reads it, after the rename. A name the rule refuses is `invalid_body`, with the rule's own sentence as its `detail`. The creator's role is checked again as the name is written, so a key whose creator stopped managing the team while the call ran is refused with `forbidden` and nothing changes. A retry with the same Idempotency-Key answers the first response.
Security: apiKey (team:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
name | string | yes | The organization's new name, as the dashboard will show it. Trimmed; the same rule as naming a new organization. |
Response
| field | type | required | description |
|---|---|---|---|
organization | object | yes | |
organization.id | string | yes | The organization's id. |
organization.slug | string | yes | The organization's short name, as its dashboard addresses show it. |
organization.displayName | string | yes | The organization's name, as the dashboard shows it. |
creatorRole | "owner" | "admin" | "billing" | "member" | yes | The role the person who created this key holds in this organization now: `owner`, `admin`, `billing` or `member`. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/team/invitations
Operation customer.team.invitations-list. List this tenant's pending team invitations.
Every invitation to join the organization the key belongs to that is still pending and has not expired, oldest first, as the team page lists them: each one's id, the address it was sent to, the role it grants and when it was sent and expires. The invitation link is never listed: it is shown once, by `customer.team.invitations-create`. Needs a key whose creator manages the team now. Not paged: the page reads them all, and so does this.
Security: apiKey (team:read)
Response
| field | type | required | description |
|---|---|---|---|
invitations | array of object | yes | Every pending invitation that has not expired, oldest first. Never the invitation link: it is shown once, when the invitation is created. |
invitations[].id | string | yes | The invitation's id — what `customer.team.invitations-revoke` takes. |
invitations[].email | string | yes | The address the invitation was sent to. |
invitations[].role | "owner" | "admin" | "billing" | "member" | yes | The role accepting it grants: `owner`, `admin`, `billing` or `member`. An owner invitation was sent from the dashboard. |
invitations[].invitedAt | string | yes | When the invitation was sent. |
invitations[].expiresAt | string | yes | When the invitation stops being accepted. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/team/invitations
Operation customer.team.invitations-create. Invite someone to join this tenant's team.
Invites an address to join the organization the key belongs to, as the team page's invite form does, through the same primitive: the invitee is emailed the link, and the role must be one the key's creator may grant. A key can never invite an `owner`: an owner invites a co-owner from the dashboard. REVEAL-ONCE: `acceptPath` exists in this response and nowhere else. An address that is not valid is `invalid_body`; one that is already a member, or already has a pending invitation, is `conflict`. A retry with the same Idempotency-Key answers the first response, link included; the stored response is encrypted at rest because it holds it.
Security: apiKey (team:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
email | string | yes | The address to invite. Checked as the team page's invite form checks it. |
role | "admin" | "billing" | "member" | yes | The role accepting the invitation grants: `admin`, `billing` or `member`. Never `owner`: an owner invites a co-owner from the dashboard. |
Response
| field | type | required | description |
|---|---|---|---|
invitation | object | yes | |
invitation.id | string | yes | The invitation's id — what `customer.team.invitations-revoke` takes. |
invitation.email | string | yes | The address invited, as stored: trimmed and lower-cased. |
invitation.role | "admin" | "billing" | "member" | yes | The role accepting it grants. |
invitation.expiresAt | string | yes | When the invitation stops being accepted. |
acceptPath | string | yes | THE INVITATION LINK'S PATH, and this is the ONLY response that carries it: it holds the single-use token, which nothing stores. The invitee is also emailed the link. Hand it over, and do not persist it. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
DELETE /api/v1/team/invitations/{id}
Operation customer.team.invitations-revoke. Revoke one of this tenant's pending team invitations.
Revokes a pending invitation of the organization the key belongs to, as the team page's Revoke does, through the same primitive: its link stops working at once. Only an invitation for a role the key's creator may grant: an admin's key cannot revoke an owner invitation, and is refused with `forbidden`. An invitation that is no longer pending in this organization (accepted, declined, already revoked, or another organization's) is one `not_found`, so a repeat answers `not_found` rather than doing something new. One past its expiry that nobody answered is still pending, as on the team page: it is revoked, which makes its end final, though `customer.team.invitations-list` no longer lists it. A retry with the same Idempotency-Key answers the first revoke instead.
Security: apiKey (team:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The invitation id, as `customer.team.invitations-list` returns it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
revoked | true | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
DELETE /api/v1/team/members/{userId}
Operation customer.team.members-remove. Remove a member from this tenant.
Removes a member from the organization the key belongs to, as the team page's Remove does, through the same primitive: their access ends at once, and their API keys and the invitations they sent are revoked with it. Only a member whose role the key's creator may grant: an admin's key cannot remove an owner, and is refused with `forbidden`, even when that owner is the last one. The last owner is never removed: that is `conflict`, and the remedy is a co-owner, invited from the dashboard, who has accepted. An account that is not a member of this organization is `not_found`. Removing the key's own creator ends this key too: the call that does so is its last. A retry with the same Idempotency-Key answers the first removal instead.
Security: apiKey (team:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
userId | path | string | yes | The member's account id, as `customer.team.members-list` returns it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
removed | true | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/analytics/cost-by-label
Operation customer.analytics.cost-by-label-get. Read Cost by label: this organization's charged cost per label over a window.
The analytics page's Cost by label section for the organization the key belongs to, from the same query. Every label on a run's issue counts, and a run's full cost counts under each of its labels, so when a run carries two or more labels the rows overlap and add up to more than `totalCostMicros`, which counts every run once; `overlapStatement` says so then, and is null when the rows add up exactly. Labels are each issue's current labels, effective after mirror sync. Runs whose issue has no labels are the `unlabelled` row; runs whose issue has no mirrored copy are the `unavailable` row. `since` and `until` are a pair, as on the page: give both, or the trailing 30 days ending now are read; the answer names the window it read. Needs a key created by someone who is currently an owner of the organization, as the page does.
Security: apiKey (analytics:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
since | query | string | no | The window's first day (`YYYY-MM-DD`) or instant (ISO 8601). Read only with a valid `until` after it; otherwise both bounds are dropped and the trailing 30 days ending now are read. |
until | query | string | no | The window's last day, included (`YYYY-MM-DD`), or an instant, excluded (ISO 8601). Read only with a valid `since` before it; otherwise both bounds are dropped and the trailing 30 days ending now are read. |
rollup | query | string | no | `on` counts child and follow-up work under each label as related cost, as the page's "Include child and follow-up work" does. Any other value, or none, counts each run under its own issue's labels only. |
Response
| field | type | required | description |
|---|---|---|---|
window | object | yes | The window actually read, after the defaults and the 366-day limit. |
window.since | string | yes | The first instant read, included. |
window.until | string | yes | The instant the window ends, excluded. |
rows | array of object | yes | Highest cost first, then by name. |
rows[].kind | "label" | "unlabelled" | "unavailable" | yes | `label`: a label the runs' issue carries now (its current labels, effective after mirror sync). `unlabelled`: runs whose issue has no labels. `unavailable`: runs whose issue has no mirrored copy, so its labels are unknown. Tells a label spelled like a sentinel apart from the sentinel. |
rows[].name | string | yes | The label as the issue spells it, or the group's name for the two other kinds. |
rows[].internal | boolean | yes | True for a label Descant writes for its own bookkeeping (a residual-fingerprint dedup marker). The analytics page hides these by default; they still count, and every row is returned here. Always false for the two other kinds. |
rows[].costMicros | string | yes | The full charged cost of every run in this row, in credit micros (10,000 micros are one credit), as a decimal string. A run with several labels counts in full under each. |
rows[].runCount | integer | yes | The runs that carry this row. |
rows[].directCostMicros | string | yes | The part of costMicros whose run's own issue carries this label, in credit micros, as a decimal string. Equals costMicros unless `rollup=on`. |
rows[].relatedCostMicros | string | yes | With `rollup=on`, the part of costMicros reached only through child and follow-up work: runs on issues decomposed from, or filed as follow-ups by runs of, an issue carrying this label. A run counts once per label, as direct when its own issue carries it. With directCostMicros it adds up to costMicros; "0" without `rollup=on`. |
totalCostMicros | string | yes | Every run's cost counted once, in credit micros, as a decimal string. The rows add up to more than it when a run carries two or more labels (overlapStatement is then set), and exactly to it when none does (overlapStatement is null). |
runCount | integer | yes | Every run in the window, counted once. |
overlapStatement | string | null | yes | The sentence the analytics page shows beside the rows, saying they overlap; null when no run in the window carries two or more labels, so the rows add up to the total exactly. |
lineageUnavailableRunCount | integer | yes | With `rollup=on`, the runs whose child and follow-up lineage could not be read in full (an issue with no mirrored copy, or a follow-up filed before its creator was recorded), so their related cost may count short. 0 without `rollup=on`. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/analytics/cost
Operation customer.analytics.cost-get. Read cost analytics: this organization's charged cost over a window, its trend, mix and highest-cost runs.
The analytics page's cost sections for the organization the key belongs to, from the same read: the headline cost cards, the cost trend and mix, the run usage vs cost chart, the repository by day of week heatmap, the filter options and the per-run table. `repo` and `kind` narrow every figure but the filter options, as on the page; a `repo` whose content the key's creator may not read narrows nothing. The totals, trend and mix are whole-tenant; every run and repository row is read as the key's creator may see it, so a run in a repository they cannot read arrives as its summary, named "Repository N". Cost is charged credit micros as decimal strings; the per-run mean and percentiles may carry a fraction. `since` and `until` are a pair, as on the page: give both, or the trailing 30 days ending now are read; buckets and days are counted in UTC. The answer names the window, bucket, dimension, filters and run limit it read. When the figures cannot be read the answer is `internal_error`, never zeros. Needs a key created by someone who can currently read analytics in the organization, as the page does.
Security: apiKey (analytics:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
since | query | string | no | The window's first day (`YYYY-MM-DD`) or instant (ISO 8601). Read only with a valid `until` after it; otherwise both bounds are dropped and the trailing 30 days ending now are read. |
until | query | string | no | The window's last day, included (`YYYY-MM-DD`), or an instant, excluded (ISO 8601). Read only with a valid `since` before it; otherwise both bounds are dropped and the trailing 30 days ending now are read. |
bucket | query | string | no | The trend's and the mix's bucket: `hour`, `day`, `week` or `month`. Anything else reads `day`, as on the page. |
dim | query | string | no | What the mix decomposes cost by: `kind` (the category), `runKind` (the run type) or `repo` (the repository). Anything else reads `kind`, as on the page. |
repo | query | string | no | Only cost in this repository: its id, as `filterOptions.repositories` names it. A value that is not a repository id, or names a repository whose content the key's creator may not read, narrows nothing, and the answer's `repo` is then null. |
kind | query | string | no | Only cost in this category: `work`, `review`, `compute` or `storage`. Anything else narrows nothing, and the answer's `kind` is then null. |
Response
| field | type | required | description |
|---|---|---|---|
window | object | yes | The window actually read, after the defaults and the 366-day limit. |
window.since | string | yes | The first instant read, included. |
window.until | string | yes | The instant the window ends, excluded. |
bucket | "hour" | "day" | "week" | "month" | yes | The bucket the trend and the mix were read in. |
dimension | "kind" | "runKind" | "repo" | yes | What the mix decomposes cost by. |
repo | string | null | yes | The repository filter applied, or null when none was. |
kind | "work" | "review" | "compute" | "storage" | null | yes | The category filter applied, or null when none was. |
runLimit | integer | yes | The most rows `runs` holds: the page's per-run table size. |
summary | object | yes | The page's headline cost cards. Whole-tenant, as on the page. |
summary.totalCostMicros | string | yes | Every charged micro in the window, including account-level cost that belongs to no run: the page's Total cost card, the figure that reconciles with the balance. |
summary.runCostMicros | string | yes | Charged micros across the window's runs only, without account-level cost. |
summary.pullRequestCostMicros | string | yes | Charged micros across the runs that opened a pull request. |
summary.mergedPullRequestCostMicros | string | yes | Charged micros across the runs whose pull request merged. |
summary.runCount | integer | yes | Runs with charged cost in the window. |
summary.pullRequestCount | integer | yes | Distinct pull requests among those runs. |
summary.mergedPullRequestCount | integer | yes | Distinct merged pull requests among those runs. |
summary.issueCount | integer | yes | Distinct issues among those runs. |
summary.averageRunCostMicros | string | yes | Mean charged micros per run, every outcome, which may carry a fraction; "0" with no runs. |
summary.medianRunCostMicros | string | yes | Median charged micros per run, which may carry a fraction; "0" with no runs. |
summary.p90RunCostMicros | string | yes | 90th-percentile charged micros per run, which may carry a fraction; "0" with no runs. |
summary.p99RunCostMicros | string | yes | 99th-percentile charged micros per run, which may carry a fraction; "0" with no runs. |
trend | array of object | yes | The page's Cost over time chart, oldest bucket first. Whole-tenant, as on the page. |
trend[].bucketStart | string | yes | The bucket's first instant, its days counted in UTC. |
trend[].costMicros | string | yes | Charged micros in the bucket. |
mix | array of object | yes | The page's cost mix chart, by bucket, oldest first. Whole-tenant, as on the page; each repository is named for the key's creator. |
mix[].bucketStart | string | yes | The bucket's first instant, its days counted in UTC. |
mix[].group | string | null | yes | The group: a category with `dim=kind`, a run type with `dim=runKind`, a repository id with `dim=repo`; null for cost that carries none. |
mix[].name | string | yes | The group as the page names it: a repository the key's creator may not read is "Repository N". |
mix[].costMicros | string | yes | Charged micros in this bucket and group. |
scatter | array of object | yes | The page's Run usage vs cost chart: the highest-cost runs, highest first. A run outside the key creator's repositories is its summary. |
scatter[].access | "content" | yes | The key's creator may read this repository's content, so the row is whole (#27590). |
scatter[].runId | string | yes | The run. |
scatter[].repositoryId | string | yes | The repository's id: the value the `repo` filter takes. |
scatter[].repository | string | null | yes | The repository as `owner/name`; null when the run's repository is not recorded. |
scatter[].issue | string | null | yes | The issue the run worked, as the provider names it; null for a run with none. |
scatter[].pullRequestNumber | integer | null | yes | The pull request the run opened; null for a run that opened none. |
scatter[].costMicros | string | yes | The run's charged micros in the window. |
scatter[].units | number | yes | The run's recorded token usage in the window, the page's size axis. |
scatter[].access | "summary" | yes | The key's creator may not read this repository's content, so its name, issue and pull request are left out (#27590). |
scatter[].runId | string | yes | The run. |
scatter[].repositoryId | string | yes | The repository's id: the value the `repo` filter takes. |
scatter[].repository | string | yes | The repository as the page names it, "Repository N". |
scatter[].costMicros | string | yes | The run's charged micros in the window. |
scatter[].units | number | yes | The run's recorded token usage in the window, the page's size axis. |
repositoryDayOfWeek | array of object | yes | The page's Cost by repo & day of week heatmap: the named repositories first, then the others by number, so their place says nothing of their names. |
repositoryDayOfWeek[].access | "content" | yes | The key's creator may read this repository's content, so the row is whole (#27590). |
repositoryDayOfWeek[].repository | string | yes | The repository as `owner/name`. |
repositoryDayOfWeek[].repositoryId | string | yes | The repository's id: the value the `repo` filter takes. |
repositoryDayOfWeek[].dayOfWeek | integer | yes | 0 is Sunday through 6, Saturday, counted in UTC. |
repositoryDayOfWeek[].costMicros | string | yes | Charged micros in this repository on this day of the week. |
repositoryDayOfWeek[].access | "summary" | yes | The key's creator may not read this repository's content, so its name, issue and pull request are left out (#27590). |
repositoryDayOfWeek[].repository | string | yes | The repository as the page names it, "Repository N". |
repositoryDayOfWeek[].repositoryId | string | yes | The repository's id: the value the `repo` filter takes. |
repositoryDayOfWeek[].dayOfWeek | integer | yes | 0 is Sunday through 6, Saturday, counted in UTC. |
repositoryDayOfWeek[].costMicros | string | yes | Charged micros in this repository on this day of the week. |
runs | array of object | yes | The page's per-run table: the highest-cost runs, highest first, at most `runLimit`. A run outside the key creator's repositories is its summary. |
runs[].access | "content" | yes | The key's creator may read this repository's content, so the row is whole (#27590). |
runs[].runId | string | yes | The run. |
runs[].repositoryId | string | yes | The repository's id: the value the `repo` filter takes. |
runs[].repository | string | null | yes | The repository as `owner/name`; null when the run's repository is not recorded. |
runs[].issue | string | null | yes | The issue the run worked, as the provider names it; null for a run with none. |
runs[].pullRequestNumber | integer | null | yes | The pull request the run opened; null for a run that opened none. |
runs[].costMicros | string | yes | The run's charged micros in the window. |
runs[].eventCount | integer | yes | The charged usage events the run recorded in the window. |
runs[].access | "summary" | yes | The key's creator may not read this repository's content, so its name, issue and pull request are left out (#27590). |
runs[].runId | string | yes | The run. |
runs[].repositoryId | string | yes | The repository's id: the value the `repo` filter takes. |
runs[].repository | string | yes | The repository as the page names it, "Repository N". |
runs[].costMicros | string | yes | The run's charged micros in the window. |
runs[].eventCount | integer | yes | The charged usage events the run recorded in the window. |
filterOptions | object | yes | |
filterOptions.repositories | array of object | yes | The page's repository filter: every repository with cost or runs in the window, whatever the filters, that the key's creator may read and so narrow by. |
filterOptions.repositories[].id | string | yes | The repository's id: the value the `repo` filter takes. |
filterOptions.repositories[].name | string | yes | The repository as `owner/name`. |
filterOptions.categories | array of "work" | "review" | "compute" | "storage" | yes | The page's category filter: every category with cost in the window, whatever the filters. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/analytics/session-insights
Operation customer.analytics.session-insights-get. Read AI insights: the latest batch of suggestions to improve output and reduce spend.
The analytics page's AI insights section for the organization the key belongs to, from the same read. When the organization opted out of session-data analysis the answer is `disabled` with no items, and no insight is read. Otherwise it is the latest batch: `none` when there is no batch or it holds nothing this key's creator may see, else `ready` with when the batch was generated, the data window it was generated from, and its items in the two page groups, `improve_output` and `reduce_spend`. The operation takes no window. A model picks each insight's structured claims and suggested action; application code writes the title and body shown, and they arrive as plain JSON strings. A repository the key's creator may not read is named "Repository N" in them, never by name. When the insights cannot be read the answer is `internal_error`. Needs a key created by someone who can currently read analytics in the organization, as the page does.
Security: apiKey (analytics:read)
Response
| field | type | required | description |
|---|---|---|---|
kind | "disabled" | yes | |
items | array of object | yes | Always empty on this answer. |
items[].id | string | yes | The insight. |
items[].category | "improve_output" | "reduce_spend" | yes | The page's group: `improve_output` (Improve output) or `reduce_spend` (Reduce spend). |
items[].title | string | yes | The insight's title, as plain text. |
items[].body | string | yes | The insight's text, as plain text. |
kind | "none" | yes | |
items | array of object | yes | Always empty on this answer. |
items[].id | string | yes | The insight. |
items[].category | "improve_output" | "reduce_spend" | yes | The page's group: `improve_output` (Improve output) or `reduce_spend` (Reduce spend). |
items[].title | string | yes | The insight's title, as plain text. |
items[].body | string | yes | The insight's text, as plain text. |
kind | "ready" | yes | |
generatedAt | string | yes | When the batch was generated. |
dataWindow | object | yes | The batch's own data window: the operation takes no window of its own. |
dataWindow.start | string | yes | The first instant of the data the batch was generated from. |
dataWindow.end | string | yes | The last instant of the data the batch was generated from. |
items | array of object | yes | The batch's insights in the two page groups, in the batch's order; the page shows each group in this order. |
items[].id | string | yes | The insight. |
items[].category | "improve_output" | "reduce_spend" | yes | The page's group: `improve_output` (Improve output) or `reduce_spend` (Reduce spend). |
items[].title | string | yes | The insight's title, as plain text. |
items[].body | string | yes | The insight's text, as plain text. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/analytics/throughput
Operation customer.analytics.throughput-get. Read throughput: this organization's pull requests, cycle time and priority × complexity over a window.
The analytics page's PRs, Avg PR size, Cycle time and Priority × complexity panels for the organization the key belongs to, from the same read. A run counts when it started inside the window, and `repo` narrows to one repository whose content the key's creator may read; any other `repo` narrows nothing. Pull request sizes and cycle times cover only the pull requests with a recorded size and the runs that completed, and each comes with that count. Cost is charged credit micros as decimal strings. `since` and `until` are a pair, as on the page: give both, or the trailing 30 days ending now are read; the answer names the window and the repository filter it read. When the figures cannot be read the answer is `internal_error`, never zeros. Needs a key created by someone who can currently read analytics in the organization, as the page does.
Security: apiKey (analytics:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
since | query | string | no | The window's first day (`YYYY-MM-DD`) or instant (ISO 8601). Read only with a valid `until` after it; otherwise both bounds are dropped and the trailing 30 days ending now are read. |
until | query | string | no | The window's last day, included (`YYYY-MM-DD`), or an instant, excluded (ISO 8601). Read only with a valid `since` before it; otherwise both bounds are dropped and the trailing 30 days ending now are read. |
repo | query | string | no | Only runs in this repository: its id, as the repositories read names it. A value that is not a repository id, or names a repository whose content the key's creator may not read, narrows nothing, and the answer's `repo` is then null. |
Response
| field | type | required | description |
|---|---|---|---|
window | object | yes | The window actually read, after the defaults and the 366-day limit. A run counts when it STARTED inside it. |
window.since | string | yes | The first instant read, included. |
window.until | string | yes | The instant the window ends, excluded. |
repo | string | null | yes | The repository filter applied, or null when none was. |
pullRequests | object | yes | The page's PRs and Avg PR size cards. |
pullRequests.count | integer | yes | Distinct pull requests opened by runs started in the window. |
pullRequests.sizedCount | integer | yes | Those whose diff size is recorded. The size figures below cover only these, so with 0 they are 0 because nothing was measured, not because the pull requests were empty. |
pullRequests.totalAdditions | integer | yes | Lines added across the sized pull requests. |
pullRequests.totalDeletions | integer | yes | Lines deleted across the sized pull requests. |
pullRequests.averageSize | number | yes | Mean lines changed (added plus deleted) per sized pull request; 0 when none is sized. |
pullRequests.medianSize | number | yes | Median lines changed per sized pull request; 0 when none is sized. |
cycleTime | object | yes | The page's Cycle time card. |
cycleTime.completedRunCount | integer | yes | Runs started in the window that completed. The times below cover only these, so with 0 they are 0 because nothing completed. |
cycleTime.averageSeconds | number | yes | Mean seconds from a completed run's start to its finish; 0 when none completed. |
cycleTime.p50Seconds | number | yes | Median seconds from start to finish; 0 when none completed. |
cycleTime.p90Seconds | number | yes | 90th-percentile seconds from start to finish; 0 when none completed. |
byPriorityComplexity | array of object | yes | The page's Priority × complexity table: one cell per pair any run in the window carried, ordered by priority then complexity. Empty when no run started in the window. |
byPriorityComplexity[].priority | string | null | yes | The issue priority the runs were picked at, or null for runs with none (the page's "(unset)"). |
byPriorityComplexity[].complexity | string | null | yes | The issue complexity the runs were picked at, or null for runs with none (the page's "(unset)"). |
byPriorityComplexity[].costMicros | string | yes | The charged cost of this cell's runs, in credit micros, as a decimal string. |
byPriorityComplexity[].runCount | integer | yes | Runs started in the window with this priority and complexity. |
byPriorityComplexity[].prCount | integer | yes | Distinct pull requests those runs opened. |
byPriorityComplexity[].sizedPrCount | integer | yes | Those whose diff size is recorded; the line counts cover only these. |
byPriorityComplexity[].totalAdditions | integer | yes | Lines added across the cell's sized pull requests. |
byPriorityComplexity[].totalDeletions | integer | yes | Lines deleted across the cell's sized pull requests. |
byPriorityComplexity[].averageCycleSeconds | number | yes | Mean seconds from start to finish over the cell's completed runs; 0 when none of them completed. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/tenants/me/overview
Operation customer.runs.overview-get. Read this organization's merged-this-week count and whether it is brand-new.
The two Overview facts no other read answers, for the organization the key belongs to: how many runs merged in the last seven days, with the window, and whether the organization is brand-new (no repository ever connected and no runs). Each fact answers `unavailable` on its own when its read fails, never zero or false. Slots, runs, repositories and invites have reads of their own.
Security: apiKey (runs:read)
Response
| field | type | required | description |
|---|---|---|---|
mergedThisWeek | object | yes | The Overview's "Merged this week" figure. |
mergedThisWeek.status | "ok" | yes | |
mergedThisWeek.count | integer | yes | Runs that merged inside the window. |
mergedThisWeek.since | string | yes | The window's start, as an ISO instant: seven days before `until`. |
mergedThisWeek.until | string | yes | The window's end, as an ISO instant: when the read ran. |
mergedThisWeek.status | "unavailable" | yes | |
firstRun | object | yes | Whether the Overview shows this organization its first-run page. |
firstRun.status | "ok" | yes | |
firstRun.firstRun | boolean | yes | Whether the organization is brand-new: it has never connected a repository and has no runs. A repository connected and later removed still counts as connected. |
firstRun.status | "unavailable" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/billing/balance
Operation customer.billing.balance-get. Read this tenant's credit balance, this month's spend and the spend cap.
The billing page's balance for the tenant the key belongs to, from the same read: the credit balance, what runs were charged this calendar month, the monthly spend cap, why new runs are paused if they are, and whether the account was ever funded. Money is in credit micros as decimal strings. A CLOSED account still answers `ok`: the billing page keeps its balance visible after close, unlike the credit-pack and auto-reload reads, which are `conflict` then. When the billing record cannot be read the answer is `status: unavailable`, as the page shows billing unavailable.
Security: apiKey (billing:read)
Response
| field | type | required | description |
|---|---|---|---|
status | "ok" | yes | |
balanceMicros | string | yes | The credit balance. Below zero when a run's charge outran it. Credit micros as a decimal STRING, not a number: 10,000 micros are one credit. Exact, which JSON numbers are not. |
chargedThisMonthMicros | string | yes | What runs were charged this UTC calendar month: the page's "Spent this month", and what the spend cap is measured against. Credit micros as a decimal STRING, not a number: 10,000 micros are one credit. Exact, which JSON numbers are not. |
hardCapMicros | string | null | yes | The monthly spend cap; runs pause when this month's charges reach it. `null`: no cap is set. Credit micros as a decimal STRING, not a number: 10,000 micros are one credit. Exact, which JSON numbers are not. |
pauseReason | "balance" | "cap" | "operator" | null | yes | Why new runs are paused, or `null` when they are not. `balance`: the credits ran out; adding credits lifts it. `cap`: this month's charges reached the spend cap; raising the cap lifts it. `operator`: support has put the account on hold; contact support. |
everFunded | boolean | yes | Whether credits have ever been added or spent. A zero balance with this `false` is an account that was never funded. |
status | "unavailable" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/billing/spend-cap
Operation customer.billing.spend-cap-set. Set or remove this tenant's monthly spend cap.
The billing page's spend-cap form, for the tenant the key belongs to, through the same save. Only a key whose creator may manage billing now (an owner or an admin, as on the page) may set it; a closed account and one whose billing is not set up are `conflict`. The cap is whole credits, 0 or more, and `null` removes it; a value too large to store is `invalid_body`. SETTING IT CAN PAUSE NEW RUNS RIGHT AWAY: the cap is measured against this calendar month's spend, and a cap at or below what was already charged this month pauses new runs until the cap is raised or removed, or the month turns. Saving a cap never resets the month's spend. Read `customer.billing.balance-get` for the spend, the cap and why runs are paused.
Security: apiKey (billing:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
capCredits | integer | null | yes | The monthly spend cap in whole credits, 0 or more; `null` removes the cap. |
Response
| field | type | required | description |
|---|---|---|---|
capCredits | integer | null | yes | The cap now in force, in whole credits; `null` when there is none. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/billing/ledger
Operation customer.billing.ledger-list. List this tenant's credit ledger, newest first.
The billing page's transaction history for the tenant the key belongs to, from the same read: every credit movement, newest first, each with its kind, the signed amount in credit micros as a decimal string, the run a charge belongs to, when it was booked and where an adjustment came from. Narrow with `kind` and a `createdAfter`/`createdBefore` window, both inclusive. Paged by an opaque page token; follow `nextPageToken` until it is `null`. `invalid_query` covers a malformed filter or page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`. A CLOSED account still answers: the billing page keeps its history visible after close. When the ledger cannot be read the answer is `internal_error`, never an empty page.
Security: apiKey (billing:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
kind | query | "topup" | "run_charge" | "adjustment" | "refund" | no | Only rows of this kind. Absent means every kind. |
createdAfter | query | string | no | Rows booked at or after this instant, INCLUSIVE. At most millisecond precision. |
createdBefore | query | string | no | Rows booked at or before this instant, INCLUSIVE. At most millisecond precision. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | The ledger row's id. |
items[].kind | "topup" | "run_charge" | "adjustment" | "refund" | yes | `topup`: credits bought. `run_charge`: what a run cost. `adjustment`: credits granted or reversed. `refund`: an older refund. |
items[].amountMicros | string | yes | What the row moved, in credit micros as a decimal STRING, not a number: 10,000 micros are one credit. Negative for a charge. Exact, which JSON numbers are not. |
items[].runId | string | null | yes | The run a charge belongs to, or `null` for a row no run caused. |
items[].createdAt | string | yes | When the row was booked, to the millisecond. |
items[].adjustmentSource | "promo" | "operator_grant" | "platform_credit" | null | yes | Where an `adjustment` came from: `promo` (a redeemed promo code), `operator_grant` (granted by support) or `platform_credit` (a run's charge credited back). `null` for every other row, and for an adjustment with no named source. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/billing/run-costs
Operation customer.billing.run-cost-list. List what each of this tenant's runs cost, most recently charged first.
The billing page's per-run cost card for the tenant the key belongs to, from the same read: one row per charged run, most recently charged first, each with what it cost net of any credit, in credit micros as a decimal string, how many charges it accumulated, when it was last charged, and its cost by stage and role. A run's `split` is absent when it does not add up to its total; `splitsAvailable` is `false` when the split could not be read at all, and the totals still stand. Paged by an opaque page token; follow `nextPageToken` until it is `null`. A run charged again during a walk moves to the top and is not read twice. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`. A CLOSED account still answers: the billing page keeps its per-run costs visible after close. When the costs cannot be read the answer is `internal_error`, never an empty page.
Security: apiKey (billing:read)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].runId | string | yes | The run. Read it with `customer.runs.get`. |
items[].totalMicros | string | yes | What the run cost, NET of any credit given back for it: `0` for a run credited in full. Credit micros as a decimal STRING, not a number: 10,000 micros are one credit. Exact, which JSON numbers are not. |
items[].chargeCount | integer | yes | How many charges the run accumulated; a credit is not counted as one. |
items[].lastChargedAt | string | yes | When the run was last charged, to the millisecond. The list's sort key. |
items[].split | array of object | no | The run's cost by stage and role, in timeline then role order, summing exactly to `totalMicros`. Absent when it does not add up to `totalMicros` (a charge landed between the two reads), or when `splitsAvailable` is `false`. |
items[].split[].stage | "plan" | "work" | "review" | "resolve" | "merge" | "other" | yes | The stage of the run that spent it, in the run timeline's own terms. |
items[].split[].role | "author" | "reviewer" | "panels" | "other" | yes | Who spent it: the author, the reviewer, the review panels, or other. |
items[].split[].micros | string | yes | What this stage and role cost, net of the run's share of any credit. Credit micros as a decimal STRING, not a number: 10,000 micros are one credit. Exact, which JSON numbers are not. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
splitsAvailable | boolean | yes | `false` when the split read failed for this page: every `split` is then absent for that reason, and the totals still stand. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/me/notifications
Operation customer.notifications.list. List the key creator's notifications and email preference.
The notifications page's inbox for the person who created the key, in the organization the key belongs to, from the same read: the latest 50 notices, newest first, each with the title and text the page shows, when it was raised, whether it is unread and the issue its run worked on. Also whether the person receives these notices by email. When the inbox cannot be read the answer is `internal_error`, never an empty list.
Security: apiKey (notifications:read)
Response
| field | type | required | description |
|---|---|---|---|
preferences | object | yes | |
preferences.email | boolean | yes | Whether these notices are also sent by email. `true` until the person turns it off. |
notifications | array of object | yes | The latest notices, newest first. |
notifications[].id | string | yes | The notification's id. |
notifications[].title | string | yes | The notice's headline, as the notifications page shows it. |
notifications[].body | string | yes | The notice's text, as the notifications page shows it. |
notifications[].createdAt | string | yes | When the notice was raised, as an ISO instant. |
notifications[].unread | boolean | yes | Whether the notice has not been marked read yet. |
notifications[].issue | object | null | yes | The issue the notice's run worked on. `null` for a notice about no run, or about a run on no issue or in a repository the key's creator may not see. |
notifications[].issue.title | string | null | yes | The issue's title; `null` when the run did not record one. |
notifications[].issue.repo | string | yes | The repository, as `owner/name`. |
notifications[].issue.number | integer | yes | The issue's number in that repository. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/me/notifications/{id}/read
Operation customer.notifications.mark-read. Mark one of the key creator's notifications read.
Marks one notice in the inbox of the person who created the key read, in the organization the key belongs to — the notifications page's Mark as read, through the same writer. A notice already read answers the same, and keeps the time it was first read. Another person's notice, another organization's and an unknown or malformed id are one indistinguishable `not_found`, and nothing is written for any of them.
Security: apiKey (notifications:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The notification's id, as `customer.notifications.list` returns it. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Response
| field | type | required | description |
|---|---|---|---|
id | string | yes | The notification's id. |
unread | false | yes | Always `false`: the notice is read, now or from before. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
PUT /api/v1/me/notification-preferences
Operation customer.notifications.email-set. Turn the key creator's email notifications on or off.
Sets whether the person who created the key also receives their notices by email, in the organization the key belongs to — the notifications page's email toggle, through the same writer. Only the email preference is changed. Answers the preference as stored.
Security: apiKey (notifications:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
email | boolean | yes | Whether these notices are also sent by email. |
Response
| field | type | required | description |
|---|---|---|---|
email | boolean | yes | Whether these notices are also sent by email. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/settings/privacy
Operation customer.settings.privacy-get. Read whether this tenant has opted out of session-data analysis.
The data and privacy page's toggle, for the tenant the key belongs to: whether its session data is kept out of analysis and insights. A tenant that never chose reads back `false`, not opted out.
Security: apiKey (settings:read)
Response
| field | type | required | description |
|---|---|---|---|
sessionAnalysisOptedOut | boolean | yes | Whether this organization has opted out of having its session data used for analysis and insights. `true` is opted out. Setting it back to `false` needs a key created by an owner. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/settings/privacy
Operation customer.settings.privacy-set. Opt this tenant out of session-data analysis, or back in.
Saves the data and privacy page's toggle, for the tenant the key belongs to, through the same writer. `true` keeps this organization's session data out of analysis and insights; `false` opts back in, which is a consent decision, so only a key created by a current owner may make either change. Answers the setting as stored, read back after the save.
Security: apiKey (settings:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
sessionAnalysisOptedOut | boolean | yes | Whether this organization has opted out of having its session data used for analysis and insights. `true` is opted out. Setting it back to `false` needs a key created by an owner. |
Response
| field | type | required | description |
|---|---|---|---|
sessionAnalysisOptedOut | boolean | yes | Whether this organization has opted out of having its session data used for analysis and insights. `true` is opted out. Setting it back to `false` needs a key created by an owner. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/settings/sso
Operation customer.sso.get. Read this organization's single sign-on connection.
The single sign-on settings page, for the organization the key belongs to: whether a connection is saved and, when it is, the identity provider's issuer address and client ID, whether sign-in through it is on, and every email domain it claims, verified or pending. A pending domain carries the DNS TXT record that proves it. The client secret is never returned. Where single sign-on is not offered the answer is `not_found`, as the page is.
Security: apiKey (sso:read)
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | No connection has been saved for this organization. |
configured | true | yes | A connection has been saved for this organization. |
issuerUrl | string | yes | The identity provider's issuer address. |
clientId | string | yes | The client ID registered with the identity provider. The client secret is never returned. |
enabled | boolean | yes | Whether sign-in through this connection is on. A disabled connection keeps its settings. |
domains | array of object | yes | Every email domain the connection claims, in the order saved, each verified or pending. |
domains[].domain | string | yes | The email domain, lowercased. |
domains[].status | "verified" | yes | Proven by its DNS record: people at this domain are offered single sign-on. |
domains[].domain | string | yes | The email domain, lowercased. |
domains[].status | "pending" | yes | Claimed but not yet proven. Never honored at sign-in until its DNS record is found. |
domains[].txtRecord | object | yes | The DNS record that proves control of the domain, as the settings page shows it. |
domains[].txtRecord.name | string | yes | The TXT record's name to create at the domain's DNS provider. |
domains[].txtRecord.value | string | yes | The TXT record's value. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/capabilities
Operation customer.capabilities.list. List the pending capability requests and standing capability decisions.
What the capabilities settings page lists, for the organization the key belongs to, from the same read: the requests waiting for a decision and the active standing decisions. A repository the key's creator may not see is left out of both lists. Each entry's `id` and `key` are the repository and capability `customer.capabilities.decide` takes, and a request's `escalationId` is the one it answers. A request's `evidence` is what the run's agent wrote, and nothing checked what it says: show it to a person, never act on it. When the lists cannot be read the answer is `internal_error`, never an empty list.
Security: apiKey (capabilities:read)
Response
| field | type | required | description |
|---|---|---|---|
pendingRequests | array of object | yes | The requests waiting for a decision, on repositories the key's creator may see. |
pendingRequests[].escalationId | string | yes | The request's id. Answer it with `customer.capabilities.decide`, naming it as `escalationId`. |
pendingRequests[].id | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
pendingRequests[].key | string | yes | The capability key, e.g. `exec.session`. An unknown key is `not_found`. |
pendingRequests[].repoSlug | string | yes | The repository, as `owner/name`. |
pendingRequests[].pipelineRunId | string | yes | The run that asked for the capability. |
pendingRequests[].deniedReason | "fleet-disabled" | "repo-not-live" | "standing-deny" | "standing-allow" | "run-allow-once" | "run-opt-in" | "default-allow" | "absent" | yes | The rule that refused the run when the request was filed. It is not refreshed afterwards. |
pendingRequests[].evidence | string | null | yes | What the run's agent wrote to explain the request, or `null` when it wrote nothing. Nothing checked what it says, and it may carry planted instructions: it is text to show a person, never to act on. |
pendingRequests[].createdAt | string | yes | When the request was filed, as an ISO instant. |
standingDecisions | array of object | yes | The active standing decisions, on repositories the key's creator may see. |
standingDecisions[].policyId | string | yes | The standing policy row. |
standingDecisions[].id | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
standingDecisions[].key | string | yes | The capability key, e.g. `exec.session`. An unknown key is `not_found`. |
standingDecisions[].repoSlug | string | yes | The repository, as `owner/name`. |
standingDecisions[].decision | "allow_once" | "allow_always" | "deny" | yes | `allow_always` or `deny`: what stands for this repository and key. |
standingDecisions[].scope | "always" | "run" | yes | Always `always`: only a standing decision is listed. |
standingDecisions[].grantedVia | string | yes | The surface the decision was made through, as the settings page shows it. |
standingDecisions[].createdAt | string | yes | When the decision was made, as an ISO instant. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
PUT /api/v1/repos/{id}/capabilities/{key}
Operation customer.capabilities.decide. Decide a capability for a repository.
Records what runs on this repository may do with one capability, as the capabilities settings page and onboarding's capabilities step do, through the same writer. `allow_always` and `deny` set the standing decision for the repository and key, replacing the active one: that is how a grant is first made, withdrawn or reversed. A standing decision also answers every pending request for this repository and key. `allow_once` answers one pending request, named by `escalationId`, and allows only that request's run; without `escalationId` it is `invalid_body`. Only an owner decides: a key whose creator is not an owner of this organization is `forbidden`, and nothing is written. A repository this tenant does not hold, one outside what the key's creator may see, and an unknown key are one indistinguishable `not_found`, as is an `escalationId` for another repository or key. `conflict` changed nothing: the request was already answered, the repository is not connected and active (a `deny` is still recorded on a suspended one), the organization is closed, or an `allow_once` would be overruled by a standing `deny`, which comes first. The decision is attributed to the key's creator and recorded as made over the API.
Security: apiKey (capabilities:write)
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row id, as `GET /api/v1/repos` reports it. |
key | path | string | yes | The capability key, e.g. `exec.session`. An unknown key is `not_found`. |
Idempotency-Key | header | string | no | A replay with the same key and the same body WITHIN 24 HOURS returns the first response rather than acting twice. The exception is a first call that answered 5xx or failed: it may have acted, so the key answers `outcome_unknown` and never runs again. After 24 hours the key is forgotten, and a call carrying it is a new request. |
Request body
| field | type | required | description |
|---|---|---|---|
decision | "allow_once" | "allow_always" | "deny" | yes | `allow_always` and `deny` are standing decisions for the repository: each replaces the active standing decision for this key, and answers every pending request for it. `allow_once` allows one run, and needs `escalationId`: it answers that request's run and nothing else. |
escalationId | string | no | A pending request for this repository and key, which this decision answers. Required with `allow_once`, optional otherwise. One for another repository or key is `not_found`; one already answered is `conflict`. |
Response
| field | type | required | description |
|---|---|---|---|
decision | "allow_once" | "allow_always" | "deny" | yes | The decision recorded. |
scope | "always" | "run" | yes | `always`: it stands for the repository until replaced. `run`: it allows the one run the request named. |
policyId | string | null | yes | The policy row the decision wrote. |
escalationId | string | null | yes | The request this decision answered, or `null` when none was named. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. / idempotency_key_in_flight — The first request with this Idempotency-Key is still running; retry with the same key and body shortly. |
| 422 | application/problem+json | idempotency_key_reused — This Idempotency-Key was used with a different body. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 504 | application/problem+json | outcome_unknown — The action may have taken effect; verify before retrying. |
GET /api/v1/me/security
Operation customer.security.get. Read the sign-in security of the person who created this key.
The security settings page for the person who created the key: whether an authenticator is paired and, when it is, its label, when it was paired and how many recovery codes are unused; whether the account has a password; and whether removing the authenticator would be refused because the person owns an organization. Never a secret, a recovery code or a password. A closed account is `conflict`.
Security: apiKey (security:read)
Response
| field | type | required | description |
|---|---|---|---|
secondFactor | object | yes | The authenticator app paired with the account, if any. |
secondFactor.enrolled | false | yes | No authenticator is paired. |
secondFactor.enrolled | true | yes | An authenticator is paired: sign-in asks for its code. |
secondFactor.label | string | null | yes | The device label given when pairing; `null` when none was given. |
secondFactor.pairedAt | string | yes | When the authenticator was paired, as an ISO instant. |
secondFactor.recoveryCodesRemaining | integer | yes | How many recovery codes are still unused. The codes themselves are never returned. |
hasPassword | boolean | yes | Whether the account has a password. `false` for an account that signs in only through a connected provider or single sign-on. |
mustKeepSecondFactor | boolean | yes | Whether removing the authenticator would be refused because this person owns an organization: an owner must keep one paired. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/problem+json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/problem+json | forbidden — The key is valid but may not run this operation. Either its scopes do not include the one this operation requires (the `security` entry names it), or the account that created the key has not confirmed its email address (#11855): then the body's `detail` says so, and confirming the address makes the same key usable. Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
Changelog
What changed in the contract and why, newest first. An additive change keeps the version and is recorded under it; a breaking change is a new major and a new document.
Version 3.0.0
customer.repos.connect,customer.repos.reconnectConnecting or reconnecting a repository with a key whose creating account has no usable GitHub link now answers `409 github_link_required` instead of `503 upstream_unavailable`. The old answer told a caller to retry, and no retry could succeed. Link or reconnect GitHub on that account, then call again; the refusal releases its Idempotency-Key, so the same key works. `upstream_unavailable` now means only a coverage lookup that did not complete.
customer.analytics.session-insights-getRead AI insights from the API, CLI and MCP: the analytics page's latest batch of suggestions to improve output and reduce spend, with its data window and when it was generated, or that the organization opted out. Repositories the key's creator cannot read are named "Repository N". Reading needs `analytics:read` on a key whose creator can currently read analytics.
List the pending capability requests and the standing capability decisions from the API, CLI and MCP, as the capabilities settings page shows them and through the same read. A repository the key's creator may not see is left out. Each entry names the repository and key `customer.capabilities.decide` takes. Reading needs `capabilities:read`, a scope no existing key holds.
A list row with `access: content` gains `attempts`: how many runs of its issue, in the same repository, the request's own filter reaches (its repository, states and created window), across every page rather than this one. Every row of one issue carries the same value. A summary row carries none. Additive.
One run gains `drift` and `chargesReversed`, the two facts its page already showed. `drift` says what happened to a finished run's pull request after it was left open for a person to merge (`merged_externally`, `closed_externally` or `needs_rebase`), and is `null` otherwise. `chargesReversed` is `true` when the run's charges were reversed because it stopped due to a problem on our side. Both are read whole only: a run read as `summary` leaves them out, as it leaves out `findings`, `heldDisclosure`, `heldStepNote`, `instructionLikeContentNotes` and `baseBranch`.
A single run declares `quarantinedCount` (review findings set aside as unsafe to show) and `worthinessDemotedCount` (findings demoted as not worth a fix), the two counts the run page renders that the schema left out. Both were already in every response; a client that validated responses against the strict schema refused them until now.
customer.runs.split-scorecard-getA decomposing run's split scorecard: each leaf of the split, what its runs did, and what the split's outcomes say about where it was cut, as the run page shows it. Branch on `access`, then `kind`: `absent` is a run that never decomposed and `unreadable` one whose stored record cannot be read; a key whose creator may not read the repository gets `access: "summary"`.
customer.repos.list,customer.repos.getEach repository now carries its recorded onboarding facts: `installations` says whether the author and review Apps are installed (`installed` or `absent`), `lastVerifiedAt` when validation last verified them and `onboardingCompletedAt` when onboarding completed, each `null` until it happens. They are what Descant has recorded; reading them checks nothing with GitHub. The fields are additive.
Reconnect a disconnected repository from the API, CLI and MCP, as the account page's Reconnect does and through the same steps: the repository is re-proved on GitHub for the key's creator, as a new connection is, and moves back to `pending`, so it still owes validation. A repository that is not disconnected, or that an organization admin detached, answers `conflict`. It needs `repos:write` and a creator who may disconnect repositories, and a retry with the same Idempotency-Key answers the first response.
Validate a repository's install from the API, CLI and MCP, as the onboarding validate page does and through the same steps: both Apps must reach the repository, a verification issue is opened and closed in it, and the repository moves to `active`, including back from `suspended` or `revoked`. An install that is not right answers `conflict` naming what to fix. It needs `repos:write` and a creator who may change repository settings, and a retry with the same Idempotency-Key answers the first response.
Disconnect a repository from the API, CLI and MCP, as the repository settings page does and through the same writer, confirmed by the repository's `owner/name`. A repository already disconnected answers `conflict`, as `activate` does. It needs `repos:write` and a creator who may disconnect repositories, and a retry with the same Idempotency-Key answers the first response.
customer.repos.update,customer.repos.configThe update now writes everything the repository settings page saves: `defaultBaseBranch`, `autoMergeEnabled`, `excludeAssigned`, `workflowsWriteEnabled`, `areaLabels` and `orderQueries`, each left alone when absent. `null` clears the area labels and restores the default ordering. Values the settings page refuses are refused as `invalid_body`. The config read returns the base branch, the area labels and the workflow-authoring setting beside the rest.
Mail an invite's link to an address, as the dashboard's invite dialog does. It takes the code `customer.invites.create` revealed, because the link cannot be rebuilt from an id, and answers `202` with `status: "sent"`: accepted for sending, not delivered. An unknown, foreign, redeemed or revoked code is one `not_found`, and sends share the account's per-account send budget with the dashboard.
customer.billing.auto-reload-set`enabled` is now optional. Leaving it out keeps auto-reload on or off as it is and saves the threshold and target you send, as the billing page's save-amounts form does. While auto-reload stays off nothing is charged, and the saved pair is checked when it is next turned on. While it stays on, the save can buy one reload right away, as turning it on does; `reload` says what happened.
customer.settings.repo-defaults-setSet the repository defaults from the API, CLI and MCP, as the settings page saves them: both toggles, and optionally what happens to a pull request a run abandons. The tenant is the key's. Setting them needs `settings:write`, and a retry with the same Idempotency-Key answers the first response.
Set the account's maximum concurrent runs from the API, CLI and MCP, through the same save as the usage page. The cap is shared by every organization the account owns. It needs `settings:write` on a key whose creator owns the organization, and a retry with the same Idempotency-Key answers the first response.
customer.team.organization-getRead the organization a key belongs to from the API, CLI and MCP: its id, short name and display name, and the role the key's creator holds in it now. It needs `team:read`, and answers only the key's own organization.
customer.team.invitations-listList the organization's pending team invitations from the API, CLI and MCP: each one's address, role, and when it was sent and expires. It needs `team:read`, on a key whose creator manages the team. The invitation link is never listed.
customer.team.organization-rename,customer.team.invitations-create,customer.team.invitations-revoke,customer.team.members-removeRename the organization, invite someone to it, revoke a pending invitation and remove a member from the API, CLI and MCP, as the team page does. Each needs `team:write`, on a key whose creator manages the team, and the creator's grant limits apply. A key can never invite an owner, and the last owner is never removed. A retry with the same Idempotency-Key answers the first response.
Read cost analytics from the API, CLI and MCP: the analytics page's cost cards, trend, mix, highest-cost runs, repository by day of week heatmap and filter options over a window, narrowed by repository and category. Runs and repositories the key's creator cannot read arrive as their summary. Reading needs `analytics:read` on a key whose creator can currently read analytics.
customer.analytics.throughput-getRead throughput from the API, CLI and MCP: the analytics page's pull request count and size, cycle time and priority × complexity table over a window, narrowed by repository. Reading needs `analytics:read` on a key whose creator can currently read analytics.
Read the Overview's merged-this-week count and whether the organization is brand-new from the API, CLI and MCP. Each answers `unavailable` on its own when its read fails. Reading needs `runs:read`.
Read the credit balance from the API, CLI and MCP, as the billing page shows it: the balance, this month's spend, the spend cap, why runs are paused and whether the account was ever funded, in credit micros as decimal strings. A closed account still answers. Reading needs `billing:read`.
customer.billing.spend-cap-setSet or remove the monthly spend cap from the API, CLI and MCP, through the same save as the billing page. A cap at or below this month's spend pauses new runs, as it does on the page. It needs `billing:write` on a key whose creator may manage billing, and a retry with the same Idempotency-Key answers the first response.
List the credit ledger from the API, CLI and MCP, as the billing page's transaction history shows it: every credit movement newest first, in credit micros as decimal strings, narrowed by kind and an inclusive date window and paged by token. A closed account still answers. Reading needs `billing:read`.
customer.billing.run-cost-listList what each run cost from the API, CLI and MCP, as the billing page's per-run card shows it: one row per charged run, most recently charged first, net of credits in credit micros as decimal strings, with its cost by stage and role, paged by token. A closed account still answers. Reading needs `billing:read`.
customer.notifications.mark-read,customer.notifications.email-setMark one of the key creator's notifications read, and turn their email notifications on or off, from the API, CLI and MCP, as the notifications page does and through the same writers. A notice that is not the creator's, or does not exist, is `not_found`. Both need `notifications:write`, a new scope no existing key holds, and a retry with the same Idempotency-Key answers the first response.
List the key creator's notifications from the API, CLI and MCP, as the notifications page shows them: the latest notices newest first, with whether each is unread and the issue it concerns, and the email preference. Reading needs `notifications:read`, a new scope no existing key holds.
customer.settings.privacy-get,customer.settings.privacy-setRead and set whether the organization's session data is kept out of analysis and insights, from the API, CLI and MCP, as the data and privacy page does and through the same writer. The tenant is the key's. Reading needs `settings:read` and setting needs `settings:write`; either change needs a key created by a current owner. A retry with the same Idempotency-Key answers the first response.
Read the organization's single sign-on connection from the API, CLI and MCP, as the settings page shows it: the issuer address and client ID, whether it is on, and each email domain with its verification status and DNS record. Never the client secret. Reading needs `sso:read`, a new scope no existing key holds.
Decide a repository's capability from the API, CLI and MCP, as the capabilities settings page does and through the same writer: grant it, withdraw it, or answer a pending request. Only a key whose creator is an owner may decide. It needs `capabilities:write`, a new scope no existing key holds, and a retry with the same Idempotency-Key answers the first response.
Read the sign-in security of the person who created a key from the API, CLI and MCP, as the security settings page shows it: whether an authenticator is paired, how many recovery codes are unused, whether the account has a password, and whether an owner must keep the authenticator. Never a secret or a code. Reading needs `security:read`, a new scope no existing key holds.
customer.runs.list,customer.runs.getA run carries `access`. `content`: the key's creator may read the run's repository, and the run reads as before. `summary`: they may not, and the run's state, timeline, clocks and counts are returned without their repository content: a list row without `issue`, `issueTitle`, `prNumber`, `repoOwner` and `repoName`; a single run without `findings`, `heldDisclosure`, `heldStepNote`, `instructionLikeContentNotes` and `baseBranch`. A key reads as the member who created it. Branch on `access` before reading those fields.
`labelMap` is now a map from Descant's label key to your label, the shape the config read returns it in, where it was a list of `KEY=label` entries. An empty map, like `null`, clears the mapping. The command-line tool still takes it as `--label-map <KEY>=<label>`, repeated.
customer.repos.grooming.dispatchThe conflict fence `expected` is now a map from issue reference to the `updatedAt` you hydrated with, where it was a list of `ref=updatedAt` entries. The command-line tool still takes it as `--expected <ref>=<updatedAt>`, repeated.
The draft's `labelMap` is now a map from Descant's label key to your label, where it was a list of `KEY=label` entries. An empty map, like `null`, previews with no mapping. The command-line tool still takes it as `--label-map <KEY>=<label>`, repeated.
customer.settings.engine-tier-getRead the engine tier back from the API, CLI and MCP, in the vocabulary setting it answers with. The tenant is the key's. Reading needs `settings:read`.
customer.analytics.cost-by-label-getCost by label takes `rollup=on` to count child and follow-up work under each label: every row gains `directCostMicros` and `relatedCostMicros`, which add up to its cost, and the answer gains `lineageUnavailableRunCount`.
A start gains the refusal `deliverable-not-code` (a `conflict`): the issue reads as a decision or a question rather than code work, so it is set aside for a person, with a comment and the needs-human label, and nothing is billed. The scheduler has always set such issues aside; a start over the API now does too. Edit the issue to describe the code change, or have a person start it from the dashboard. When the issue could not be judged this time, the start answers `upstream_unavailable` and a later attempt is judged again.
customer.repos.poller-diagnosticsDiagnostics now carry `lastPollRefusals`: when the last poll refused every issue it examined, how many it examined and what each refusal was. The counts are taken before the poll checks each issue on its own terms, so a held repository can show issues that pass the filters and still start nothing; this field says so. `wouldQualify` is unchanged on the wire and now describes what it counts.
customer.repos.update,customer.repos.configThe label mapping became readable and writable, as one pair. The config read returns `labelMap`, the stored mapping from Descant's label keys to the repository's own labels. The update accepts `labelMap` as `KEY=label` entries, the spelling the order preview already takes: a list replaces the stored mapping, and `null` or an empty list clears it. A mapping spelled like the eligibility label is refused. The mapped labels are not created on the repository.
customer.analytics.cost-by-label-getRead Cost by label from the API, CLI and MCP: this organization's charged cost per label over a window, the same figures as the analytics page. A run's full cost counts under each of its labels. Reading needs `analytics:read` on a key created by a current owner.
One repository by id: the same fields the repository list returns, for the repository you name. An id your tenant does not hold answers not found.
customer.settings.repo-defaults-getRead the repository defaults from the API, CLI and MCP: what newly connected repositories start with, and what happens to a pull request a run abandons. Reading needs `settings:read`. There is no API write for them yet.
Read the profile of the person who created a key from the API, CLI and MCP: display name, email address and whether it is verified, avatar image, and connected sign-in methods. It needs `settings:read`, on a key whose creator still belongs to the organization.
Read this organization's concurrency usage from the API, CLI and MCP: its running, waiting and parked runs, and the account-wide pool it shares, with the account's cap and free slots. Reading needs `settings:read`. There is no API write for the cap yet.
List the organization's members from the API, CLI and MCP: each member's address, role and when they joined. Reading needs `team:read`, a new scope no existing key holds, because the roster is personal data. There is no API write for membership.
The read no longer carries `commitPlanDocument`. The setting is retired: a plan file is never committed to your repository. The other fields are unchanged.
customer.repos.grooming.documentAn issue the scheduler will not start because its suppression still stands (a cancelled run waiting for a comment, for example) is now held in the document with the `suppressed` label and the same release sentence the order preview shows, and a `serialize-with` group's next issue is chosen the way the scheduler chooses it around such an issue.
customer.billing.auto-reload-get,customer.billing.auto-reload-setAuto-reload now brings the balance back up to a target instead of buying a fixed amount. The settings read `targetCredits` and `defaultTargetCredits` in place of `amountCredits` and `defaultAmountCredits`. A save takes `targetCredits`; the old `amountCredits` is still accepted for one release and read as a target of the threshold plus that amount.
Version 2.0.0
customer.settings.engine-tier-setEngine tier selection names soprano-7.
Version 1.0.0
customer.repos.update,customer.repos.configThe default documentation directory is now `docs/`, where it was `docs/solutions/` alone, so a repository that declares none has its whole documentation directory kept current, a configuration reference included. Plan documents under `docs/plans` stay out unless you list `docs/plans` (or a directory inside it), and a wider directory never brings them in. A declared list is unchanged.
customer.billing.credit-pack-list,customer.billing.credit-pack-buyBuy a fixed credit pack with the saved payment method from the API, CLI and MCP, the same purchase as the billing page's one-click buy. List the packs and the saved method first (`billing:read`), then buy (`billing:write`) sending back the method's id and the pack's terms: the purchase charges only if both still hold, as the page's confirm step does. Both need a key created by a current owner. An `Idempotency-Key` is required, so a retried purchase answers the first result and never charges twice.
customer.billing.auto-reload-get,customer.billing.auto-reload-setRead and set auto-reload from the API, CLI and MCP, through the same save as the billing page: turning it off keeps the stored threshold and amount, and turning it on validates them as the page does. Turning it on while the balance is already low buys one reload right away; `reload` says what happened. Reading needs `billing:read` and setting needs `billing:write`, on a key created by a current owner.
customer.runs.list,customer.runs.get`endingClass` gains `unknown`: this read cannot name how the run ended. For a recent run that is usually a new kind of ending this read does not know yet, and a later read names it; before this, such a run could read `unexpected` for a few minutes although it had ended as designed. An older run whose ending no list names can stay `unknown`; read `state` and `endingCause` for what is known.
Start a run over the API: one issue of a connected repository, through the same checks the dashboard's start form makes, answering the new run's id. Every refusal says whether waiting can help, and a refusal the service named carries `reason` so a caller can branch on it. Send an `Idempotency-Key` so a retried start answers the first result instead of starting and billing a second run. A start over the API is treated as unattended, so an issue whose latest run failed is refused rather than run again; a person can still start it from the dashboard.
A run's activity log: its attempts at each step, oldest first, in the words the run's activity page uses. `outcome` says how each attempt ended and `outcomeText` says it in words, with the cause on a failed attempt. The log is a bounded tail: the run's most recent 500 attempts. `attemptsRead` and `attemptsTotal` say whether that is all of them; earlier attempts of a longer run are not returned.
Restart a run that ended failed — the same run, not a new one. The answer says whether it started or waits for a free slot (`awaitingSlot`). A restart re-bills, so it needs a key whose creator is currently an owner of the organization, honours `Idempotency-Key`, and answers `outcome_unknown` rather than a retryable code when it cannot tell whether the restart landed.
customer.runs.list,customer.runs.get`endingCause` now names the cause of an `unexpected` ending too, as the key we recorded (such as `code:unrecoverable_error`), where it read `null` before. `endingClass` still says whether the list of expected endings names it. `null` now means the run has not ended or, rarely, that the ending recorded no readable cause.
Each `stepDetails` sub-step now describes the four fields the run page already sent: `retryReason` (why the most recent retry happened, or `null`), `verifySkipped` and `verifySkipReason` (a verification step that verified nothing, and why: `not-configured` is the one you can act on), and `testsCheckCaveat` (a tests check that checked nothing). The response already carried them; the published schema now says so, so a validating client accepts it (#16118).
One run gains `instructionLikeContentNotes`: a sentence for each place the run met text that reads as instructions to the agent (in the issue, a repository file or a thread) and did not follow. It is filled for every run, including one that opened no pull request, where it is the only place you are told. An empty list means none was flagged.
customer.runs.list,customer.runs.getEach run gains `endingClass` and `endingCause`: how the run ended, from the one list of expected endings. `state` stays coarse and now says so. A run held for a person that opened no pull request reads `state: "done"`, the same as a run that shipped, and a run you stopped by closing its issue reads `state: "failed"`. Key on `endingClass` (`done`, `predictable`, `outage_park` or `unexpected`, and `null` while the run has not ended) to tell them apart. `done` covers delivered work and named reasons there was nothing to deliver, so read `endingCause` (and `prNumber` on a list row) to tell those two apart.
`dispatchWaiting` gains `paused-operator-hold`: support has put the account on hold, so a resting run waits until the hold is lifted. Adding credits does not lift it; contact support. Before this, such a run read `dispatchWaiting: null`, the same as a run queued for no reason, and only `billing.pauseReason: "operator"` said otherwise.
customer.repos.update,customer.repos.configA per-repository automatic pickup switch. `automaticPickup: false` on the update stops Descant starting work on that repository by itself, while runs you start by hand keep working; `paused` stops both. The config read returns `automaticPickup`.
A new `status.kind`, `pickup-off`: automatic pickup is turned off for the repository, so Descant starts nothing there on its own while runs you start by hand still go ahead. It is read from the switch, so the status no longer keeps reporting the last poll from before pickup went off.
`page-self-filed-paced` is no longer reported for a new poll: a repository whose next issues were all filed by earlier runs there is now worked like any other, with no daily limit. A stored outcome still returns that kind until the repository's next poll, and its `nextSlotAt` is always `null`.
A new `status.kind`, `page-self-filed-paced`: when every issue a repository would pick up next was filed by earlier runs there, it now works through them one run a day instead of stopping, and `nextSlotAt` says when the next may start. Each of those runs is billed like any other. `page-self-filed-only` remains for rows written before the change.
customer.repos.grooming.candidates,customer.repos.grooming.next-candidatesThe candidates read can now go past its first page. `customer.repos.grooming.next-candidates` takes the candidate ids you have already answered and holds them back before the page bound, so each page is proposals still to answer. Both reads now return `withheld` (how many current proposals your answers held back) and `nextPage` (whether a further read returns more).
customer.repos.order-preview,customer.repos.ordering-previewBoth previews now answer from the local copy of the repository's issues, never a live provider search, so the `upstream_*` errors are gone. `configured: false` now carries `reason: "mirror-importing"`; the whole-walk answer drops `partial`, `searchIncomplete` and `tierTruncated` and always carries `mirroredAsOf`; the per-query answer is discriminated on `outcome` (`matched`, `unsupported-term`, `mirror-importing`).
customer.repos.update,customer.repos.configA repository's documentation directories became a setting: `docRoots` on the update names the directories whose documentation Descant keeps current, and `null` resets them to the default. The config read returns the stored list and the effective one beside it. A directory is never included unless it is listed.
customer.settings.engine-tier-setA tenant can now set its own engine tier from the API, CLI and MCP rather than only in a browser. The tenant is the key's — no request can name another. `changed: false` is a successful no-op: re-sending the tier you are already on writes nothing, which is what lets a tenant grandfathered onto a RETIRED engine save without being migrated off it. The vocabulary is the customer-visible set, so the hidden internal validation tier is neither offered nor nameable here. On a zero-data-retention deployment a non-eligible tier is refused before the write rather than through runs that fail later.
Setting the eligibility label now CREATES it on the repository, closing the gap the 2026-09-16 entry left open: a label the repository did not carry made pickup find nothing, forever, with no error anywhere. In `labeled` mode the write creates the stored label once the write itself has landed; in `all-issues` mode nothing is created, because the label gates nothing there. When the label could not be created the write still succeeds and the response carries `eligibilityLabelWarning`, a partial success telling you to create the label by hand — absent whenever the label is there.
customer.repos.connect,customer.repos.activateConnecting a repository and activating it became customer operations, closing the gap where the first step of onboarding was the one step no key could take. Both responses carry `awaiting`: activation and unpausing are two different columns written by two different calls, and a repository can be active and still idle.
The deployed contract's table of contents: every operation id it serves and the version it was built from, unauthenticated and served from the registry alone. `descant version --check` now asks it and reports how many operations — and which — a binary is behind the deployment, so “run the CLI from main when the release is behind” becomes a sentence a seat can act on rather than one it has to already know the answer to.
The eligibility label and eligibility mode became writable, closing the second gap where the config read showed a setting no API could change.
The run list is paged and filterable. It had been capped at fifty rows since it shipped — the handler asked the query layer for no limit and got that primitive's own default — so a tenant past its fiftieth run was reading a truncated list the response shape called complete. Follow `nextPageToken` until it is `null`, and narrow with `repositoryId`, a repeatable `state`, or a `createdAfter`/`createdBefore` window. THE ROWS MOVED FROM `runs` TO `items`, deliberately: the shared page envelope is what every other list here uses, and a renamed field is a break you can see at the first response rather than a silent truncation you cannot. The scope is unchanged — still ownership-scoped, so a removed repository's runs are still listed.
One run, as its own page shows it: the five canonical phases with their statuses and mapped copy, the server clocks a stall is measured against, the review findings and the follow-up counts. It renders the SAME projection the dashboard does, so the two cannot tell a customer different stories about one run — the raw failure code and the raw review checkpoint stay behind it, as they do on the page.
customer.repos.issues.list,customer.repos.labels.listThe two repository pickers, which until now existed only as lists a person clicked: the issues a run can be started for, and the label names the eligibility setting matches on. Both answer `configured: false` as a SUCCESS where this deployment cannot enumerate — enqueueing by issue number and setting a label by name keep working, so that case is a shape to branch on rather than an error to retry.
customer.auth.device.start,customer.auth.device.token`descant login` now opens a browser by default. These two unauthenticated operations are that handshake: one mints a short code a person approves in the dashboard, the other polls until they have, and answers the resulting key once. Pasting a key still works — `descant login --key`, or any non-terminal stdin — and what the browser flow stores is an ordinary tenant key in the ordinary place, so precedence, `whoami` and `descant mcp` are unchanged.
customer.invites.list,customer.invites.create,customer.invites.revokeInvites, described by the contract the routes were already serving: list an account's invites with the budget left, mint one and read its code the single time it is ever shown, and revoke one to reclaim its slot. They act on the account that CREATED the key rather than the tenant's current owner, so which key you use decides whose budget you spend — and a key with no creating account resolves to nothing, the same answer as asking about somebody else's invite.
customer.repos.grooming.candidates,customer.repos.grooming.document,customer.repos.grooming.dispatchThe grooming workspace, for callers that have no workspace: the relationships Descant would propose with the evidence that suggested each one, the document the issues currently encode, and the single fenced mutation that lands an edit into the issue bodies. The reads are served from the local copy of the backlog, because a proposal is a proposed write against the copy you are looking at — and when that copy cannot serve they refuse with a reason rather than returning an empty list, which is what a working detector returns over a backlog with nothing to propose.
customer.repos.order-preview,customer.repos.ordering-previewBoth ordering previews, for callers with no settings form: the whole walk under an unsaved draft, and what one query matches. The draft is a partial overlay and omitting a field keeps the saved value — except `orderQueries`, where `null` clears and absent keeps, which are different requests. Neither writes anything.
The key list is now paged, taking the same opaque page token every other list here does. It shipped unpaginated on the reasoning that a tenant's key count is bounded by people minting them, and the mint beside it falsified that: a key holding `keys:write` mints without a person, and a revoke is soft, so the list only ever grows. Follow `nextPageToken` until it is `null`.
What a repository is configured to PICK — the label that makes an issue eligible, the order the picker walks, and whether assigned issues are excluded. Effective values, with the defaults already composed and no flag saying whether a row existed, because an absent row and a default-valued one pick identically.
The first customer WRITE, and the one that stops spend. It answers WHICH ending the cancel reached rather than a boolean — `cancel_requested` means recorded but not yet stopped and calls for a re-read, `already_finished` is final. An `outcome_unknown` answer means the cancel may have landed: verify before calling again. The `billing_paused` code and its own CLI exit were minted alongside it, ahead of the first operation that declares them — review-and-fix carries the runner's typed billing reason, and cancel cannot produce one.
Changing what a repository picks, not just whether it is paused: the pickup order and the per-repo concurrency sub-cap became writable, closing the gap where the config read could show an order no API could change. A partial patch — an absent field is left alone, and a null sub-cap clears it.
customer.repos.poller-diagnosticsWhy nothing is being picked when the poller IS running — how many issues carry the eligibility label, and which buckets hold the rest. A live provider call, so the answer is a union: branch on `configured`, then on `mode`, before reading any count, because a deployment without author-App credentials and an `all-issues` repository cannot produce counts, and fabricated zeros would read as "nothing is eligible" rather than "this was not measured".
The first contracted customer operation: a tenant's runs, newest first, with the billing signal beside them, under the API key's own tenant.
The tenant's repositories — the entry point for every repo-scoped operation, since each takes the repo id this list is the only programmatic source of.
One repository's recorded settings intents, newest first: who asked for which setting and when, as the product recorded it.
Why a repository is or is not being polled — the read that answers 'why is nothing happening', with the cause named rather than a boolean, because each cause has its own remedy.
The Model Context Protocol endpoint, so an agent can drive these operations as typed tools. Its scope is the envelope and not the contents: every tool call inside is still checked against the scope its own operation requires.
Rotation without a gap: a successor carrying the target's label and scopes, and a grace window in which the old key still authenticates, so a caller can swap the secret before the old one stops.
customer.keys.list,customer.keys.create,customer.keys.revokeThe credential surface itself: a tenant's keys, a mint that can hold no scope the minting key does not already hold, and a revoke. Attenuation is what makes a narrow key reachable without the console.
The trail every authenticated call leaves, made readable: one row per call any of this tenant's credentials made, refusals included, so a refusal can be attributed to a credential rather than guessed at.