The developerz.ai REST API is served at https://api.developerz.ai.
Most of what it does is also reachable over the
MCP server, so use whichever fits your client. Claude Code and
Claude Desktop connect over MCP; scripts and CI workflows typically use REST. The
two surfaces are not yet a mirror image of each other: the Capability column
below is the id they share, so comparing it against the
MCP verb list tells you which of the two serves what you need.
Authentication
Create a personal access token (PAT) in the dashboard under
Settings → API tokens, then pass it as a bearer token:
curl https://api.developerz.ai/v1/me \
-H "Authorization: Bearer dev_pat_…"
Tokens are scoped at creation time. See Scopes below.
PAT capability asymmetries
Multi-org context: The dashboard UI session can switch organizations, toggling which org a request sees via the X-Dz-Account header. PATs ignore that header: a token minted in one account stays bound to that account for its entire lifetime. The header is membership-fenced before any route runs on UI sessions, never trusted from a PAT request. If you need to work with multiple organizations, issue a separate PAT in each one.
Personal-only surfaces: The subroutes under /v1/me/ (personal keys, preferences, system prompt) and the routes under /v1/notifications* (inbox, preferences) need a human behind the credential, because "me" has to resolve to somebody. What decides that is whether the token names a user, not that it is a token: a PAT you mint while signed in carries your user id and reaches your own personal data as you, while a token with no human behind it (a box connection token, or a PAT minted before tokens recorded their minter) returns 403 Forbidden on all of them. GET /v1/me is not in that set and always answers: it is the self-check, and it reports the calling credential's own scopes.
Base URL
https://api.developerz.ai
Every path below is absolute and carries its own prefix. The capability surface
is versioned under /v1; the handful of routes that are not (/join,
/unsubscribe, /actions/:jwt, /gh/installed) are the unversioned legs a
browser, a mail client or a fresh box hits without a bearer token.
Collections
Every endpoint that returns a list answers one envelope, so you write one pager
and it works everywhere:
{ "data": [ ... ], "next_cursor": "b3AxMDA" }
Send next_cursor straight back as ?cursor= and never parse it: it is opaque
and its internals will change. ?per_page= sets the page size, and a value above
a collection's maximum is refused rather than quietly clamped, so you always know
what you asked for is what you got.
Paging is keyset. A cursor names a position in the collection, not a row count to
skip, so it carries the order it was minted under and the sort keys of the last
row it gave you. Two things follow. A bare ?cursor= is enough: you do not have
to repeat the sort or the per_page that produced it, and changing per_page
mid walk continues from the same row instead of rebasing. And rows written while
you page never shift the ones you have not read yet, which is what an offset
could not promise.
There is no ?page=. A page number is an offset by another name, and on a
collection anything is appending to it re-serves rows you already have. Sending
one is refused by name rather than ignored, and the error points at
next_cursor. Re-sending the same ?sort= alongside a cursor is fine, since a
generic pager appends its base parameters to every request; sending a different
one is a 400, because the keys you are holding address a place in the old order.
next_cursor: null means there is nothing after this page. That is a promise, not
a placeholder. Some collections are always served in full (your org roster, the
connectors on an account, the notification category matrix) and those answer
null every time and take no paging parameters at all. Any collection that could
truncate hands you a real cursor instead, so a null never hides rows you have
not seen.
Two endpoints are the exception and say so in their own description: GET /v1/sessions
and GET /v1/prs/pending-approvals are bounded summary views behind ?limit=, so a full
page there can mean more rows exist and no cursor reaches them. Narrow the request rather
than paging it. Everything else keeps the promise above.
?per_page= is the page-size parameter. ?limit= was an older spelling of it on
the endpoints that published both, and it is gone: send it and the call is a 400
whose detail names per_page as the replacement, rather than being quietly
ignored. Three reads spell their page size limit and keep it, because they never
had a ?per_page= beside it and it is their own canonical knob:
GET /v1/sessions, GET /v1/prs/pending-approvals and GET /v1/digests.
Endpoints
Generated from the route table the app mounts, so this list cannot drift from
what is running. It covers every capability endpoint; the browser login legs
(/auth/* and the OAuth callbacks) are not here because they mint a credential
rather than spend one.
The Scope column carries what a caller must present. A scope in backticks is
asserted by the route. no bearer marks a route mounted before the token guard,
whose description names what authorizes it instead (a signed link, a one-time
enrollment token, a webhook signature). per request marks a route that
authorizes each call inside its handler, so it publishes no single floor.
Account
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/me |
account.me |
read:account |
Identity + self-check: the scopes this credential holds, plus user, memberships, active account and ToS state. approved is the closed-beta gate, and it is a TRI-STATE: true cleared it, false is waitlisted, null means this credential names no human to approve (a machine token). Absent or null is never a refusal, so read the field rather than treating "not true" as denied. |
POST |
/v1/me/active-account |
account.active_account_switch |
write:account |
Repoint the live user session at a member account. |
POST |
/v1/me/accept-tos |
account.accept_tos |
read:account |
Record the signed-in user's Terms of Service consent. |
GET |
/v1/admin/overview |
account.platform_overview |
admin:read:platform |
Platform radar rollup (staff read). |
GET |
/v1/admin/audit |
account.platform_audit |
admin:read:platform |
Platform-wide audit feed (staff read). The account-scoped feed is /v1/audit/events. |
GET |
/v1/admin/platform-audit |
account.platform_trail |
admin:read:platform |
The platform-admin trail: every operator read of a tenant’s data, naming who looked, whose data, which run or account, and when. The other half of account.platform_audit, which records what the platform did FOR a tenant; this records what it did TO one. Keyset-paged, newest-first. |
GET |
/v1/admin/goals |
account.platform_goals |
admin:read:platform |
The platform's own goal board (staff read): every milestone and type/epic issue, graded from live GitHub state with a status, an item-weighted percent, and days since anything last closed. Answers 503 with a named reason rather than an empty board when it cannot be read. |
PATCH |
/v1/admin/goals/:id |
account.platform_goal_rename |
admin:goals |
Rename one goal, writing THROUGH to GitHub: the milestone or type/epic issue is retitled at the source, and nothing is stored here. Its own scope, so a staff radar session can read the board without being able to retitle it. 422 on an id this board never mints, 404 on one GitHub does not have, 503 when GitHub cannot be reached. |
GET |
/v1/admin/accounts |
account.orgs_list |
admin:read:orgs |
All org accounts (staff read), newest-first. q searches the GitHub login. |
GET |
/v1/admin/accounts/:id |
account.org_get |
admin:read:orgs |
One account in detail (staff read). |
GET |
/v1/admin/users |
account.users_list |
admin:read:users |
All users (staff read), newest-first. q searches the login; filter=status:approved|waitlisted narrows by approval state, and omitting it reads the whole roster. |
GET |
/v1/admin/oss-verifications |
account.oss_verify_list |
admin:read:orgs |
The queued OSS-verified applications (staff read). Defaults to filter=status:pending (the queue proper); filter=status:all reads decided history, and an unknown value is a 400 rather than a silent fallback. Ruling on one is operator-only, over MCP. |
GET |
/v1/admin/fleet/runs/:runId |
account.platform_run_get |
admin:read:runs |
One agent run’s pulse, whichever account owns it; the owning org is resolved server-side and returned. The cross-tenant debugging read: an ordinary run read is fenced to your own account, and platform staff need to diagnose a run belonging to a customer they are not a member of. Every call appends a trail row before it answers. |
GET |
/v1/admin/fleet/runs/:runId/events |
account.platform_run_tail |
admin:read:runs |
SSE: one agent run’s durable event transcript, whichever account owns it. Backfill, tail, terminate, the same stream task.run_tail serves a tenant, with the owner resolved server-side. Not resumable: Last-Event-ID is refused rather than ignored. Every call appends a trail row before the first frame. |
GET |
/v1/next-steps |
account.next_steps |
per request |
What still stands between this credential and a first scout call, as an ordered ladder of rungs. Each rung carries the fact it was decided from and WHERE it clears, so a rung no verb can move is never retried. Pass ?repo=owner/repo to ask about ONE repo: the same ladder narrowed to it, plus whether it is onboarded and whether its stored secrets sit where a lane is handed them. Every rung is reported every time, passing ones included; a rung whose fact sits behind a scope you do not hold reports unknown naming that scope rather than passing. The same answer the next_steps MCP verb serves, from the same implementation. |
GET |
/v1/oss/verification |
account.oss_verification_get |
read:billing |
This account's latest OSS-verified application, or null. |
POST |
/v1/oss/verification |
account.oss_verification_apply |
write:billing |
Apply for OSS-verified. Amends the open ask while pending, 409 once approved. It grants nothing by itself: the verdict is operator-only. |
Personal settings
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/me/keys |
self.keys_list |
write:self:keys |
List the caller’s PERSONAL BYOK keys, masked. The org pool is /v1/billing/keys. |
POST |
/v1/me/keys |
self.key_add |
write:self:keys |
Add a personal BYOK key, sealed on write and never echoed back. |
POST |
/v1/me/keys/models |
self.provider_models |
write:self:keys |
List the models a provider key can address, before a PERSONAL key is captured. The key rides in the body and is never stored; the org pool’s door is /v1/billing/providers/models. |
POST |
/v1/me/keys/:id/verify |
self.key_verify |
write:self:keys |
Verify a personal key against its provider (live probe). |
POST |
/v1/me/keys/:id/rotate |
self.key_rotate |
write:self:keys |
Replace a personal key’s secret in place, keeping its id. |
DELETE |
/v1/me/keys/:id |
self.key_delete |
write:self:keys |
Delete a personal BYOK key. |
PATCH |
/v1/me/keys/:id/kind |
self.key_kind_set |
write:self:keys |
Re-stamp a personal key’s billing nature (subscription ⇄ metered). |
GET |
/v1/me/llm-pref |
self.llm_pref_get |
write:self:keys |
The caller’s personal model preference. |
PUT |
/v1/me/llm-pref |
self.llm_pref_set |
write:self:keys |
Set the caller’s personal model preference. |
DELETE |
/v1/me/llm-pref |
self.llm_pref_clear |
write:self:keys |
Clear it, back to the account default. |
GET |
/v1/me/github-token |
self.github_token_get |
write:self:keys |
Whether a personal GitHub token is connected, its expiry and repo grants. Status only; the token is never echoed. |
POST |
/v1/me/github-token |
self.github_token_set |
write:self:keys |
Connect a personal GitHub token, sealed on write. |
DELETE |
/v1/me/github-token |
self.github_token_clear |
write:self:keys |
Disconnect it. |
GET |
/v1/me/system-prompt |
self.system_prompt_get |
write:self:prompt |
The caller’s personal system prompt. |
PUT |
/v1/me/system-prompt |
self.system_prompt_set |
write:self:prompt |
Set it. |
DELETE |
/v1/me/system-prompt |
self.system_prompt_clear |
write:self:prompt |
Clear it. |
API tokens
| Method |
Path |
Capability |
Scope |
Description |
POST |
/v1/tokens |
token.mint |
write:account |
Mint a dev_pat_… PAT with scopes ⊆ mintable_scopes. The plaintext is returned exactly ONCE; only its hash is stored. Body-less mints the caller’s whole mintable set. name is an optional human label: every PAT carries the same prefix, so it is the only field that CAN tell two apart afterwards. Absent leaves the token unnamed; names need not be unique. |
GET |
/v1/tokens |
token.list |
read:account |
PATs, masked, revoked rows included, each with its expires_iso (null ⇒ permanent), plus mintable_scopes: the exact vocabulary THIS caller may mint. Empty ⇒ the caller can neither mint nor revoke. |
PATCH |
/v1/tokens/:id |
token.rename |
write:account |
Relabel a PAT. Moves the human name and nothing else (no scope, no expiry, no revocation state), and a revoked row is renameable so a roster stays readable. |
DELETE |
/v1/tokens/:id |
token.revoke |
write:account |
Revoke a PAT (idempotent, account-scoped). |
Org members
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/org/members |
member.list |
read:account |
The org’s members and their roles. |
POST |
/v1/org/members |
member.invite |
write:members |
Invite someone to the org by login (an already-registered user, added to the roster at once) or by email (anyone: a one-time invitation link, also mailed to them). |
PATCH |
/v1/org/members/:userId |
member.set_role |
write:members |
Change one member’s role. |
DELETE |
/v1/org/members/:userId |
member.remove |
write:members |
Remove a member from the org. |
GET |
/v1/org/members/invitations |
member.list_invitations |
read:account |
The org’s org_member invitations, every status, newest first. |
DELETE |
/v1/org/members/invitations/:invitationId |
member.revoke_invitation |
write:members |
Withdraw one PENDING invitation (idempotent). Refuses an already-accepted one; remove the member instead. |
Invitations
| Method |
Path |
Capability |
Scope |
Description |
POST |
/v1/invitations/accept |
invitation.accept |
read:account |
Redeem an invitation token for the signed-in identity. Grants platform access or the invited org membership, and answers a named reason when it grants nothing. |
Org SSO
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/org-sso |
org_sso.get |
read:account |
The org’s SSO configuration. |
PUT |
/v1/org-sso |
org_sso.set |
write:account |
Set it (OIDC or SAML). The browser login legs are separate, unversioned routes. |
DELETE |
/v1/org-sso |
org_sso.delete |
write:account |
Delete it; the org falls back to platform login. |
Repositories
| Method |
Path |
Capability |
Scope |
Description |
GET |
/gh/installed |
project.install_landing |
no bearer |
The GitHub App Setup URL, where GitHub drops the human after an install. No bearer, and it provisions nothing: the install webhook stays the only writer of accounts and repos. |
GET |
/v1/public/oss/:owner/:repo/stats |
project.public_stats |
no bearer |
Public activity and backer counts for ONE enabled, public, donations-opted repo. No bearer. Cached, but the eligibility gate is re-run on every positive hit, so a repo that goes private or opts out drops out immediately. |
GET |
/v1/repos |
project.list |
read:repos |
The account’s repositories with enabled state; rows carry policy_valid and last activity. |
POST |
/v1/repos |
project.create |
write:repos |
Mint a brand-new repo under the account org from the versioned gold-standard template, then dispatch the setup session that scaffolds it. Answers the created repo, the artifact manifest and a scaffold_task_id which may be null: the repo exists either way, so a failed dispatch is reported rather than thrown. Present only where the deployment holds GitHub App credentials. |
GET |
/v1/repos/:owner/:repo |
project.get |
read:repos |
One repository by owner/repo. |
PATCH |
/v1/repos/:owner/:repo |
project.update |
write:repos |
Flip a repo’s two owner switches, enabled and donations_enabled. At least one is required and an absent field is untouched state. Enabling a private repo on a plan that does not cover it is a 402 naming the way out, never a 403. |
GET |
/v1/repos/:owner/:repo/policy |
project.policy_get |
read:repos |
The stored .maintainer.yml policy snapshot for one enabled repo. |
GET |
/v1/repos/:owner/:repo/overview |
project.overview |
read:repos |
Activity rollup for one enabled repo (the repo-detail Overview tab). |
GET |
/v1/repos/:owner/:repo/settings |
project.settings |
read:repos |
Settings view for one enabled repo (the repo-detail Settings tab). |
POST |
/v1/repos/:owner/:repo/policy/validate |
project.policy_validate |
write:repos |
Check a .maintainer.yml body: {yml} in, ok or errors out. Stores nothing. |
POST |
/v1/repos/:owner/:repo/setup |
project.setup |
write:tasks |
Point a box at a repo that already exists and have it audit the project and establish its base (stack detect, the one-command DX scripts, .mcp.json, a project brain). 202 with the dispatched task_id. It rides write:tasks, not write:repos: a credential that may enable a repo is not thereby allowed to spend the account’s inference budget on it. |
POST |
/v1/repos/:owner/:repo/ask |
project.ask |
read:repos |
Ask a question about one enabled repo, answered from grounded evidence with citations. A body of {question}; the answer is never uncited, so {refused: true, reason} at 200 is the capability working rather than a failure. |
Issues
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/repos/:owner/:repo/issues |
issue.list |
read:issues |
One enabled repo’s tracked issues newest-first, with triage state. |
GET |
/v1/issues/:id |
issue.get |
read:issues |
One issue by id. |
POST |
/v1/issues/:id/handoff |
issue.handoff |
write:issues |
Force-fire the issue to the coding-agent webhook (queued delivery; reason plus an optional webhook_ref). |
POST |
/v1/issues/:id/escalate |
issue.escalate |
write:issues |
Force-pull a human in: category + reason → an escalation row and a notification. |
Pull requests
| Method |
Path |
Capability |
Scope |
Description |
GET |
/actions/:jwt |
pr.action_link_confirm |
no bearer |
Show the confirmation page for a signed approve/skip link an ask-before-acting email carries. Verifies the link and applies nothing, so a mail scanner or link prefetcher cannot spend it: 400 malformed, 410 expired. |
POST |
/actions/:jwt |
pr.action_link_redeem |
no bearer |
Apply the decision a signed approve/skip link carries. The signed JWT in the path is the whole credential and is also the CSRF token, and the link is single-use: 400 malformed, 410 expired, 409 already redeemed. |
GET |
/v1/approvals |
pr.pending_approvals |
read:prs |
The account-wide queue of outstanding ask-before-acting approvals. |
GET |
/v1/repos/:owner/:repo/prs |
pr.list |
read:prs |
One enabled repo’s PRs newest-first. |
GET |
/v1/prs/:id |
pr.get |
read:prs |
One PR by id. |
POST |
/v1/prs/:id/approve-pending |
pr.approve_pending |
write:prs |
Approve the PR’s outstanding ask-before-acting action. |
POST |
/v1/prs/:id/skip-pending |
pr.skip_pending |
write:prs |
Decline the PR’s outstanding ask-before-acting action. |
Code review
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/prs/:id/review-runs |
review.status |
read:prs |
The PR’s AI code-review runs, newest-first. |
POST |
/v1/prs/:id/review-runs |
review.rerun |
write:prs |
Re-run the PR’s latest code review on the same commit (a fresh push already auto-reviews). 202 with the revived task; rerun: false means one was already queued, which is an idempotent no-op. A PR with no review run yet is a 409, not a 404: the PR is fine, there is simply nothing to re-run. |
Tasks
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/fleet/runs/:runId/events |
task.run_tail |
read:tasks |
SSE: one BYOVM run’s durable event timeline (the NATS→SSE bridge). Backfill, tail, terminate. Resumable: each frame’s id: is the row’s seq, so a reconnect carrying Last-Event-ID continues from it instead of replaying the run. |
GET |
/v1/tasks |
task.list |
read:tasks |
The account’s task queue, newest-first. |
GET |
/v1/queue/status |
task.stalls |
read:tasks |
Why is nothing moving: every reason holding a pending task, how many it holds, how long it has been true, and what clears it. |
GET |
/v1/tasks/:id |
task.run_status |
read:tasks |
One task: the queue row, its scoped-plan detail and its run pulse. 404 when unknown or foreign. |
GET |
/v1/tasks/:id/attempts |
task.attempts |
read:tasks |
Every attempt the task has burned, oldest first: one rung per run, each with its own outcome and its own re-derived failure kind. The task row records only the LATEST attempt’s classification, so this is the only surface that says what went wrong on attempts 1 and 2 of 3. 404 when the task is unknown or foreign. |
POST |
/v1/tasks/:id/reassign |
task.reassign |
write:tasks |
Pull one in-flight task off its box back to pending so the fleet assigner re-claims it elsewhere. Idempotent: a pending or terminal task answers reassigned: false. |
POST |
/v1/tasks/:id/cancel |
task.cancel |
write:tasks |
STOP one task: it moves to cancelled and the box holding it is interrupted. The destructive sibling of reassign, which only moves the work elsewhere. Idempotent: an already-terminal task answers kind: "already_terminal", not an error. One kind of task is refused (409 not_cancellable_review): a PR review whose pull request is still OPEN, because terminating it would release the merge gate’s review hold and let the pull request merge with no review verdict. Once that pull request is merged or closed the hold is moot and the stop is accepted; the platform also retires such reviews itself. |
GET |
/v1/tasks/:id/events |
task.run_events |
read:tasks |
The same run timeline as plain JSON, paged on ?since_seq= for a client that cannot hold the SSE tail open. An unstarted task answers run_id: null and an empty page, never a 404. Send ?wait_ms= to WATCH it: the request is held open until the first new event lands (or the wait expires), so one call covers what 25 polls would, and the wait also spans the gap before a box starts the task. An empty page after a wait is not an error, it means nothing happened in that window: re-send with the same cursor. A run that has already ended settles the wait at once, so an instant empty page means the run is over rather than idle. |
POST |
/v1/tasks |
task.create |
write:tasks |
Dispatch one scoped task to a sink (linear | gh | direct) → {task_id, sink, url}, an escalation, or queue_full. |
Blockers
| Method |
Path |
Capability |
Scope |
Description |
GET |
/blocker-fix/:jwt |
blocker.fix_link_form |
no bearer |
Show the prefilled secret form a missing_credential blocker hands a human: the exact env-var names the blocked work needs, ready to fill in. Verifies the signed link and stores nothing, so a mail scanner or link prefetcher cannot spend it: 400 malformed, 410 expired. Needs no login: the signed JWT in the path is the whole credential. |
POST |
/blocker-fix/:jwt |
blocker.fix_link_redeem |
no bearer |
Store the supplied values into the repo’s secret vault and close the blocker, which puts the work back into triage. The signed JWT in the path is the whole credential and is also the CSRF token; the link is single-use, expires within the hour, may write into exactly one (repo, environment) and can never read a stored value back. 400 an incomplete form, 409 already redeemed, 410 expired. |
GET |
/v1/blockers |
blocker.list |
read:tasks |
The answerable holds: the questions the platform is waiting on a human for. Oldest-first while open, because the oldest unanswered question is the one holding work up longest; open=false widens to the closed record, newest-first. A missing_credential row carries the env-var NAMES it needs and, when one can be minted, a signed single-use fix_url a human can fill in without the agent plane ever holding the value. It never carries the answer text. |
POST |
/v1/blockers/:blockerId/answer |
blocker.answer |
write:tasks |
Supply what was missing. Closes the blocker and files a raw task carrying both the ask and the answer, so the answer is durable and admitted rather than lost with the stopped run. It does NOT restart that run: triage decides the raw task on the account’s own key and may schedule, drop, cross-link or escalate it. Answering twice is refused with a 409, never doubled: one blocker mints at most one raw task. Needs a credential that names a human, since the answer is attributed to the person who gave it. Send the LOCATION of a credential, never the credential itself. |
POST |
/v1/blockers/:blockerId/withdraw |
blocker.withdraw |
write:tasks |
Retire an ask that went away, closing it with no answer and minting no work. 404 when this account has no OPEN blocker with that id, which is also the answer for one that is already resolved. |
Plans
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/plans |
plan.list |
read:tasks |
The account’s plans, newest-activity-first: plan id, target repo, lead-slice title and the merged/total progress fraction. |
POST |
/v1/plans |
plan.start |
write:tasks |
Start a scout planning session over one enabled repo: hand it a raw ask and a mailbox-capable box authors the plan (groups then tasks). 202 with the dispatched task_id; that task’s run_id is null until a box claims and starts it, and it is the id the mailbox steers. A fleet with no online box able to hold a live session is a 409 naming the mode a box needs, not a 404: the repo is fine. |
GET |
/v1/plans/:planId |
plan.get |
read:tasks |
One plan’s full projection: the group → task → PR chain. |
POST |
/v1/plans/:planId/cancel |
plan.cancel |
write:tasks |
STOP a whole plan: every non-terminal child of its groups moves to cancelled and the boxes holding them are interrupted. Always 200 when the plan exists, even when nothing moved. The answer is three id lists: what was stopped, what had already finished, and the PR-review children that were not stopped (the same merge-gate carve-out task.cancel refuses on). |
POST |
/v1/plans/:planId/approve |
plan.approve |
write:tasks |
Release a held plan stamped when POST /v1/plans carried require_approval: true. Flips every held group back to dispatched and mints its tasks in one transaction. Always 200 when the plan exists: kind: "approved" carries the freshly-minted task ids, kind: "already_approved" is the idempotent re-approve answer (and carries the ids the FIRST call minted). 404 when no such plan. |
Runners (fleet boxes)
| Method |
Path |
Capability |
Scope |
Description |
POST |
/v1/runners/enroll |
box.enroll |
no bearer |
The box’s own enrollment leg: a runner redeems its one-time enrollment token for durable credentials. |
POST |
/v1/runners/creds/reissue |
box.creds_reissue |
no bearer |
The box rolls its own fleet credential before it expires, authenticating with its durable runner token rather than the credential being replaced. Machine leg. A revoked box is refused, which is what bounds its remaining fleet access to the lifetime of the credential it already holds. |
POST |
/v1/runners/hardening |
box.hardening_report |
no bearer |
The box’s post-harden report (firewall state, exposed-port scan). Machine leg. |
GET |
/join |
box.join_script |
no bearer |
The one-curl join script a fresh VM runs. Its credential is the one-time enrollment token, which rides the X-Dz-Enroll-Token HEADER and never the query string; this leg validates the shape but does not consume it. |
POST |
/v1/runners/enrollments |
box.add |
write:runners |
Mint a one-time enrollment token. Returned exactly once. |
POST |
/v1/runners/enrollments/ssh |
box.add_ssh |
write:runners |
Enroll over SSH instead: hand us the box’s coordinates and the mothership dials in and runs the join. 503 when the deployment has no platform SSH key. |
GET |
/v1/runners |
box.list |
read:runners |
The fleet boxes enrolled under this account, each with live presence. |
POST |
/v1/runners/:id/revoke |
box.revoke |
write:runners |
Cut a box off: its credential stops working. The reversible half is POST /v1/runners/:id/drain, which evacuates a box and leaves it enrolled. |
GET |
/v1/runners/versions |
box.versions |
read:runners |
The fleet’s dz-runner build spread right now: how many boxes run each version, live beat first and the last recorded version as fallback. |
GET |
/v1/runners/releases |
box.releases |
read:runners |
The dz-runner release ledger per build target. A tenant reads the installable builds their own boxes may be pointed at (view: installable); a platform operator holding admin:releases reads the whole ledger, halted rows and build provenance included (view: ledger). |
POST |
/v1/runners/releases/:channel/:version/unyank |
box.unyank |
admin:releases |
Restore a halted release so it re-enters the installable read. Platform-wide, so it rides the operator scope no account role grants; reports the artifact count it actually cleared. |
POST |
/v1/runners/update |
box.update |
write:runners |
Start a staged self-update rollout of THIS account’s fleet (canary, soak, bounded waves, halting itself on failure). Reports what it started, not an outcome. A second trigger joins the running rollout rather than racing it. |
GET |
/v1/runners/:id |
box.get |
read:runners |
One box in full: its row plus live presence (online, active sessions, reported version, telemetry). 404 when unknown or foreign. |
POST |
/v1/runners/:id/drain |
box.drain |
write:runners |
Evacuate a box: flip it out of the assignable pool and re-queue its in-flight work. Reversible, and the box stays enrolled. Killing one is POST /v1/runners/:id/revoke. |
POST |
/v1/runners/:id/creds/reissue |
box.reissue_creds |
write:runners |
Operator-initiated credential re-issue for a healthy box (#2547): mint a fresh fleet credential on demand. Convenience, not recovery. The verb rides the NATS control plane, which is exactly what a box with a lapsed credential has lost, so a locked-out box is systemctl restart dz-runner. A foreign id, an unknown id, and a revoked box all answer 404 (under a MEMORY resolver, refusing the roll-forward is the mechanism that makes revocation converge, and distinguishing it would tell a caller a box exists when it does not); a deployment without a NATS account signing key answers 503, the same posture the box-initiated leg carries. |
PUT |
/v1/runners/:id/owner |
box.set_owner |
write:runners |
Retarget which work a box claims: shared (the pool), org (this account) or user (one member, across their orgs). 422 when the target cannot own a box here. |
PUT |
/v1/runners/:id/prompt |
box.set_prompt |
write:runners |
Set or clear the box’s concise operator briefing (what this VPS runs, how to dispatch on it). Max 2000 characters; send null to clear. |
GET |
/v1/fleet/utilization |
box.utilization |
read:runners |
The one read the command center polls: per-box load, BYOK key pool heat, per-lane backlog depth and per-repo KB freshness, in one call. Writes nothing. |
Agent templates
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/templates |
template.list |
read:runners |
Every agent template on this account. |
GET |
/v1/templates/:name |
template.get |
read:runners |
One agent template by name. |
PUT |
/v1/templates/:name |
template.set |
write:runners |
Create or REPLACE a template by name. A full replace: an omitted field resets to its default, and a compatible backend must carry a base_url. |
DELETE |
/v1/templates/:name |
template.delete |
write:runners |
Delete a template by name. Idempotent. |
Prompt variables
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/prompt-vars |
config.list |
read:runners |
The account prompt variables, optionally narrowed to one scope and ref. |
GET |
/v1/prompt-vars/:scope/:key |
config.get |
read:runners |
One prompt variable at (scope, ref, key). The ref is a repo id or template name, and only repo/template scope takes one. |
PUT |
/v1/prompt-vars/:scope/:key |
config.set |
write:runners |
Set one prompt variable. Plaintext, so NEVER a credential: a secret-shaped value is refused, and sealed secrets belong in /v1/repos/:owner/:repo/secrets. |
DELETE |
/v1/prompt-vars/:scope/:key |
config.delete |
write:runners |
Clear one prompt variable. Idempotent. |
CI runs
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/ci/runs |
ci.runs |
read:runners |
Self-hosted CI runs, newest-first. Filter by repo and status through the shared list grammar (?filter=status:failed), not bare query params. |
GET |
/v1/ci/runs/:id/log-url |
ci.log_url |
read:runners |
A short-lived presigned URL for one run’s archived log. One 404 covers unknown/foreign, log not landed, log expired (3-day retention) and storage unconfigured, none of which a caller may distinguish. |
Artifacts
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/artifacts |
artifact.list |
read:artifacts |
Artifacts this account produced, newest-first. Narrow with run_id, task_id or repo_id; per_page caps the head (default 50, max 200) and there is no cursor, so a full page may mean more remain. |
POST |
/v1/artifacts |
artifact.put |
write:artifacts |
Record an artifact and mint a presigned PUT for its bytes. You upload to upload_url yourself; our bucket credentials never leave the control plane. Visibility is run or account only, and 503 means the deployment has no bucket to write to. |
GET |
/v1/artifacts/:id/url |
artifact.get |
read:artifacts |
A short-lived presigned GET for one artifact, alongside its metadata. One 404 covers unknown/foreign, bytes that never landed and an object already reaped. |
POST |
/v1/artifacts/:id/share |
artifact.share |
write:artifacts |
Flip an artifact to public-link and hand back a URL. Audited and outward-facing: it notifies the account, and it refuses an artifact whose bytes are missing, because a share must produce a live link. |
DELETE |
/v1/artifacts/:id |
artifact.delete |
write:artifacts |
Delete the object and its row. Idempotent. |
Sessions
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/sessions |
session.list |
read:sessions |
Recent agent sessions, account-wide. |
GET |
/v1/repos/:owner/:repo/sessions |
session.list_by_project |
read:sessions |
One repo’s sessions. |
GET |
/v1/sessions/:id |
session.get |
read:sessions |
One session: repo, trigger, outcome, model. |
GET |
/v1/sessions/:id/events |
session.events |
read:sessions |
SSE: one session’s audit events. Backfill, tail, terminate. Resumable: each frame’s id: is that row’s keyset cursor, so a reconnect carrying Last-Event-ID continues from it instead of replaying the session. |
GET |
/v1/sessions/:id/events.jsonl |
session.events_export |
read:sessions |
The same events as a JSONL download. |
POST |
/v1/sessions/:id/replay |
session.replay |
read:sessions |
Dry-run a candidate policy against a finished session. Read-only: it changes nothing. |
POST |
/v1/fleet/runs/:runId/message |
session.message |
write:sessions |
Send a message to a LIVE interactive run (a scout or a setup session) and wait for the agent’s reply. Addressed by the fleet run id, the same id GET /v1/fleet/runs/:runId/events streams, not by a session id. A run that holds no live channel is a 409; a delivered message the agent did not answer in time is a 200 carrying delivered: true, reply: null, because the guidance did land. |
Audit log
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/audit/events |
audit.tail |
read:sessions |
The account-wide audit feed, newest-first. |
Live streams
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/streams |
stream.subscribe |
per request |
ONE SSE connection multiplexing several topics (?topics=tasks,ci,…), each patch frame upserting or removing one row by id. Every topic is authorized in-handler, so the opening ready frame names which were subscribed, ignored (unknown) and unauthorized; the request 403s only when nothing survives. Not resumable: it patches derived rows rather than appending, so it issues no id: and a reconnect takes a fresh snapshot. |
Knowledge base
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/kb/:repoId/pages |
kb.page_list |
read:kb |
One repo’s knowledge-base pages, the agent-maintained wiki. |
GET |
/v1/kb/:repoId/search |
kb.search |
read:kb |
Trigram-rank the repo wiki (?q=, ?limit= 1..25). Each hit carries slug, score, cross-links and a preview excerpt; pull a full body with the page read. |
GET |
/v1/kb/:repoId/pages/:slug{.+} |
kb.page_get |
read:kb |
One KB page verbatim by slug: body, cross-links, provenance. |
PUT |
/v1/kb/:repoId/pages/:slug{.+} |
kb.page_upsert |
write:kb |
Create or replace one page by slug: a FULL replace, so an omitted links clears the page’s cross-links. Idempotent by (repo, slug). The repo must be enabled: writing into an offboarded repo’s wiki is a 409 naming the fix, not a silent no-op. |
Dashboards
| Method |
Path |
Capability |
Scope |
Description |
POST |
/v1/dashboards |
dashboard.create |
write:dashboards |
Create a named board (201). A name already in use is a 409 naming the board it would have destroyed; replace: true is the only consent to rebuild one, and a seeded built-in is never replaceable. |
GET |
/v1/dashboards |
dashboard.list |
read:dashboards |
The account-shared boards. |
GET |
/v1/dashboards/:id |
dashboard.get |
read:dashboards |
One board with all its widget specs. |
PATCH |
/v1/dashboards/:id |
dashboard.update |
write:dashboards |
Rename a board and/or change its layout. Widgets are untouched. |
DELETE |
/v1/dashboards/:id |
dashboard.delete |
write:dashboards |
Delete a board (idempotent). A seeded built-in is refused. |
POST |
/v1/dashboards/:id/widgets/:wid/page |
dashboard.widget_page |
read:dashboards |
Page one widget’s data without re-running the whole board. |
Digests
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/digests |
digest.list |
read:digests |
Digest header rows, newest-first. |
GET |
/v1/digests/:id |
digest.get |
read:digests |
One digest, with its rendered HTML. |
Notifications
| Method |
Path |
Capability |
Scope |
Description |
GET |
/unsubscribe |
notification.unsubscribe_page |
no bearer |
The confirm page behind an email footer link, authorized by the signed token in that link. It NEVER mutates, because mail scanners and link prefetchers issue GETs. |
POST |
/unsubscribe |
notification.unsubscribe |
no bearer |
Perform the opt-out, on the same signed token. Serves both the confirm form and RFC 8058 one-click, and is scoped to ONE category, so escalations can never be silenced. 400 tampered, 410 expired. |
GET |
/v1/me/notification-preferences |
notification.prefs_list |
read:account |
The resolved kind × channel notification matrix. |
PUT |
/v1/me/notification-preferences/:kind/:channel |
notification.pref_set |
write:account |
Override one kind/channel pair. |
DELETE |
/v1/me/notification-preferences/:kind/:channel |
notification.pref_clear |
write:account |
Clear an override, back to the default. |
GET |
/v1/notifications |
notification.inbox_list |
read:account |
The in-app notification inbox. |
POST |
/v1/notifications/:id/read |
notification.mark_read |
read:account |
Mark one notification read. |
POST |
/v1/notifications/read-all |
notification.mark_all_read |
read:account |
Mark the whole inbox read. |
Web push
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/push/public-key |
push.public_key |
read:account |
The VAPID public key a browser needs before it can subscribe. |
GET |
/v1/push/subscriptions |
push.subscriptions_list |
read:account |
The Web Push endpoints registered for this user. |
POST |
/v1/push/subscriptions |
push.subscribe |
read:account |
Register a Web Push endpoint. |
DELETE |
/v1/push/subscriptions |
push.unsubscribe |
read:account |
Forget one Web Push endpoint. |
Email routes
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/email-routes |
email_route.list |
read:account |
The resolved notification category → address matrix. |
PUT |
/v1/email-routes/:category |
email_route.set |
write:account |
Override one category’s route. |
DELETE |
/v1/email-routes/:category |
email_route.delete |
write:account |
Clear an override, back to the default. |
Outbound webhooks
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/webhooks |
webhook.list |
read:webhooks |
The outbound webhook subscriptions. |
GET |
/v1/webhooks/deliveries |
webhook.deliveries |
read:webhooks |
The delivery ledger: what we sent and what answered, newest first. Narrow it to one endpoint with ?filter=webhook_id:<uuid> rather than paging the whole account. |
POST |
/v1/webhooks |
webhook.create |
write:webhooks |
Subscribe a URL to a set of events. The signing secret is minted here and returned exactly ONCE; it is sealed at rest and never re-read. |
DELETE |
/v1/webhooks/:id |
webhook.delete |
write:webhooks |
Delete a subscription. |
POST |
/v1/webhooks/:id/test |
webhook.test |
write:webhooks |
Send a test delivery to one subscription. |
Integrations
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/integrations |
integration.list |
read:integrations |
The account’s connected integrations. Status only; credentials never cross the boundary. |
POST |
/v1/integrations |
integration.connect |
write:integrations |
Connect a service by pasting its credential, sealed on write and never echoed. |
DELETE |
/v1/integrations/:kind |
integration.disconnect |
write:integrations |
Disconnect a connector: drops the row and its vault secret. |
Account secrets
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/secrets |
secret.list |
read:secrets |
The account’s named secrets: names and timestamps only, never a value. |
POST |
/v1/secrets |
secret.set |
write:secrets |
Store a named secret, sealed before anything else touches the value. |
DELETE |
/v1/secrets/:name |
secret.delete |
write:secrets |
Delete one named secret. Only the dashboard: namespace is deletable here; an integration’s secret is refused at the edge. |
Repository secrets
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/repos/:owner/:repo/secrets |
project_secret.list |
read:secrets |
One repo’s stored secrets: name, exposure, environment and timestamps ONLY. A read never yields a value, because the ciphertext column is projected by exactly one query and this is not it. |
PUT |
/v1/repos/:owner/:repo/secrets |
project_secret.set |
write:secrets |
Store one repo’s sealed .env for one environment, as a whole pasted .env (parsed server-side) or as explicit rows. environment is required and never defaulted, and a malformed line refuses the WHOLE paste naming only its line number. |
DELETE |
/v1/repos/:owner/:repo/secrets/:name |
project_secret.delete |
write:secrets |
Delete one repo secret by name, optionally scoped to one environment. |
System prompts
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/system-prompts/org |
system_prompt.org_get |
write:prompts |
The org-wide system prompt. |
PUT |
/v1/system-prompts/org |
system_prompt.org_set |
write:prompts |
Set it. |
DELETE |
/v1/system-prompts/org |
system_prompt.org_clear |
write:prompts |
Clear it. |
GET |
/v1/system-prompts/repo/:repoId |
system_prompt.project_get |
write:prompts |
One repo’s system prompt. |
PUT |
/v1/system-prompts/repo/:repoId |
system_prompt.project_set |
write:prompts |
Set it; the repo layer wins over the org one. |
DELETE |
/v1/system-prompts/repo/:repoId |
system_prompt.project_clear |
write:prompts |
Clear it. |
LLM routing
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/llm-routes |
llm_route.list |
read:billing |
The model-routing pins: which BYOK key serves which purpose, at org and repo scope. |
PUT |
/v1/llm-routes/org/:purpose |
llm_route.org_set |
write:billing |
Pin an org-wide key for one routing purpose. |
DELETE |
/v1/llm-routes/org/:purpose |
llm_route.org_clear |
write:billing |
Clear the org pin for one purpose. |
PUT |
/v1/llm-routes/repo/:repoId/:purpose |
llm_route.project_set |
write:billing |
Pin a key for one purpose on one repo; the repo pin wins over the org one. |
DELETE |
/v1/llm-routes/repo/:repoId/:purpose |
llm_route.project_clear |
write:billing |
Clear the repo pin for one purpose. |
Billing
| Method |
Path |
Capability |
Scope |
Description |
POST |
/v1/billing/stripe/webhook |
billing.stripe_webhook |
no bearer |
Stripe’s own callback. No bearer: the raw body is HMAC-verified against the endpoint secret, so nothing else can call it. |
GET |
/v1/billing/keys |
billing.keys_list |
read:billing |
The ORG pool of BYOK keys, masked. The caller’s personal pool is /v1/me/keys. |
GET |
/v1/billing/usage |
billing.usage |
read:billing |
Usage rollup for the current billing window, plus the agent-hours band: used/cap in HOURS and the UTC instant they reset. null means no ceiling applies. |
GET |
/v1/billing/entitlements |
billing.entitlements |
read:billing |
The account’s tier entitlements. |
POST |
/v1/billing/portal |
billing.portal |
write:billing |
Mint a Stripe billing-portal URL. |
POST |
/v1/billing/checkout |
billing.checkout |
write:billing |
Buy seats: {tier, quantity} → a hosted Stripe Checkout URL. This is the FIRST subscription, which the portal cannot create; 503 when the tier’s price or Stripe is unconfigured. |
PATCH |
/v1/billing/cap |
billing.set_cap |
write:billing |
Set the monthly spend cap. |
POST |
/v1/billing/keys |
billing.key_add |
write:billing |
Add an org BYOK key, sealed and captured, never echoed. |
DELETE |
/v1/billing/keys/:id |
billing.key_delete |
write:billing |
Delete an org BYOK key. |
POST |
/v1/billing/keys/:id/verify |
billing.key_verify |
write:billing |
Verify an org key against its provider (live probe). |
POST |
/v1/billing/keys/:id/rotate |
billing.key_rotate |
write:billing |
Replace an org key’s secret in place, keeping its id. |
PATCH |
/v1/billing/keys/:id/kind |
billing.key_kind_set |
write:billing |
Re-stamp an org key’s billing nature (subscription ⇄ metered). |
POST |
/v1/billing/providers/models |
billing.provider_models |
write:billing |
List the models a provider key can address, before it is captured. The key rides in the body and is never stored; the answer is model ids, so a maintainer picks one instead of typing it. |
GET |
/v1/billing/keys/:id/models |
billing.key_models |
read:billing |
The same list for an org key already in the pool. |
GET |
/v1/billing/log-sink |
billing.log_sink_get |
read:billing |
The LLM-log sink config. |
PUT |
/v1/billing/log-sink |
billing.log_sink_set |
write:billing |
Set it, validated and probed and sealed before it is stored. |
DELETE |
/v1/billing/log-sink |
billing.log_sink_clear |
write:billing |
Clear it. |
Donations
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/donations |
donation.list |
read:donations |
The caller’s own gifts, newest-first. A project gift carries target_repo_full_name, derived, because a donor funds repos they do not own. |
GET |
/v1/donations/received |
donation.received |
read:donations |
The MAINTAINER’s inbox: gifts pinned to repos this account owns, each naming its giver by login (never an email) and, for an llm gift, the resolved ceiling the picker may offer. |
POST |
/v1/donations |
donation.create |
write:donations |
Give money | compute | llm, pinned to a project or to the pool, earmarking the resource oss-only. 402 without an active oss_donor subscription on a compute or llm gift; an optional models list is the ceiling the recipient picks within. |
DELETE |
/v1/donations/:id |
donation.cancel |
write:donations |
Detach a gift: parks the row and clears the earmark. The DONOR’s end. |
POST |
/v1/donations/:id/decline |
donation.decline |
write:donations |
Refuse a gift you were given: parks the row and releases the donor’s box or key. Authorized against the RECIPIENT, so the same id 404s for whichever side the caller is not. |
PATCH |
/v1/donations/:id/model |
donation.set_model |
write:donations |
The maintainer’s model pick on a received llm gift, inside the donor’s ceiling (null clears it). |
Entitlement grants
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/admin/grants |
grant.list |
admin:read:orgs |
A subject’s grant history, newest-first. Revoked and expired rows are included, each with its resolved active (staff read). |
POST |
/v1/admin/grants |
grant.issue |
admin:grants |
Operator only: give an account or a user free capacity, with a required reason and an optional expiry. |
DELETE |
/v1/admin/grants/:id |
grant.revoke |
admin:grants |
Operator only: stop a grant. It PARKS the row rather than deleting it, so an already-stopped grant answers 200 revoked: false. |
Onboarding
| Method |
Path |
Capability |
Scope |
Description |
GET |
/v1/onboarding/state |
onboarding.state |
per request |
The resumable onboarding funnel state. |
POST |
/v1/onboarding/advance |
onboarding.advance |
per request |
Advance the funnel by one CALLER-witnessed event. A platform-witnessed one (key_set, gh_app_installed, yml_validated, yml_invalid) is 422 from every state: those are observations, and their subject does not assert them. |
MCP
| Method |
Path |
Capability |
Scope |
Description |
POST |
/v1/mcp |
mcp.rpc |
per request |
The MCP server itself: one JSON-RPC endpoint for the whole verb catalog. Every verb asserts its OWN scope inside the server, which is also what buckets it for rate limiting. |
GET |
/v1/mcp |
mcp.stream_refused |
per request |
Answers 405 + Allow: POST to the GET a Streamable HTTP client opens for the server-to-client SSE leg. This server is stateless single-request JSON-RPC and pushes nothing, and the transport spec requires exactly this status to say so. The official SDK client reads 405 as "POST-only, carry on" and treats every other status, a 404 included, as a fatal transport error. |
Service
| Method |
Path |
Capability |
Scope |
Description |
GET |
/ |
service.info |
no bearer |
Service card: name, status, docs URL. No bearer, and no account state. |
GET |
/healthz |
service.health |
no bearer |
Liveness/readiness probe for the kubelet. No bearer; the DB and cache are probed with a timeout. |
GET |
/v1/openapi.json |
service.openapi |
no bearer |
This document: the OpenAPI 3.1 description of every /v1 capability, generated from the same catalog the routes are mounted from. No bearer, so a client can be generated before a token exists; per-IP rate limited, and cacheable. |
GET |
/.well-known/oauth-protected-resource |
service.oauth_protected_resource |
no bearer |
OAuth 2.1 discovery (RFC 9728): names the MCP endpoint as a protected resource, which authorization server guards it, and the scope vocabulary a grant may carry. No bearer, since it is what a hosted client reads before it has one. The /v1/mcp suffixed twin is the URL the 401 challenge points at; both serve the same document. |
GET |
/.well-known/oauth-protected-resource/v1/mcp |
service.oauth_protected_resource_mcp |
no bearer |
The same RFC 9728 document at the path-inserted URL the spec derives from the MCP endpoint (/.well-known/oauth-protected-resource + /v1/mcp). This is the URL named by WWW-Authenticate on a 401 from POST /v1/mcp. |
GET |
/.well-known/oauth-authorization-server |
service.oauth_authorization_server |
no bearer |
OAuth 2.1 authorization-server metadata (RFC 8414): the authorize, token and dynamic client registration endpoints, plus the grant types and PKCE methods accepted. Authorization code with S256 only, and public clients with no secret. |
GET |
/oauth/authorize |
service.oauth_authorize |
no bearer |
The consent screen. A browser navigation, not an API call: it resolves the dashboard session cookie, lists the exact scopes the grant would carry and the account it would be bound to, and sends a signed-out visitor to the dashboard sign-in with this request as the return target, so a finished sign-in lands back on this screen rather than on a page asking them to open a second tab. PKCE with S256 is required, and a redirect_uri that was not registered is refused on the page instead of redirected to. |
POST |
/oauth/authorize |
service.oauth_consent |
no bearer |
The Authorize button's target. Takes one sealed consent ticket bound to the session that was shown the screen, re-checks the scopes against that session's live ceiling, and redirects to the registered redirect_uri with a single-use authorization code (or error=access_denied on Cancel). |
POST |
/oauth/token |
service.oauth_token |
no bearer |
Exchange an authorization code plus its PKCE verifier for a token. The code is single-use and short-lived, and the result is an ordinary personal access token carrying the consented scopes, so it lists and revokes on the same Account tokens screen as one minted by hand. |
POST |
/oauth/register |
service.oauth_register |
no bearer |
Dynamic client registration (RFC 7591): hand in a client name and its redirect targets, get back a client_id. Anonymous by design, because a hosted connector registers before any human has proved anything, and it stores nothing: the registration is sealed into the identifier. Public clients only, so no client_secret is ever issued and PKCE is the whole client authentication. Requested grant and response types are narrowed to what this server implements and reported back in the response. |
Generated from REST_ROUTES (apps/api/src/catalog.ts). Run bun run api:doc to regenerate.
Scopes
| Scope |
Grants |
read:account / write:account |
Account metadata + tokens |
read:repos / write:repos |
Repos + policy + KB |
read:issues / write:issues |
Issue triage + handoff |
read:prs / write:prs |
PR review + approvals |
read:tasks / write:tasks |
Fleet task queue |
read:runners / write:runners |
Runner enrollment + revoke |
read:sessions / write:sessions |
Audit log + interactive sessions |
read:billing / write:billing |
Billing portal + spend cap |
read:usage |
Token usage counters |
read:digests |
Weekly digest data |
Which scopes does my token hold?
Ask it. GET /v1/me answers a scopes array holding exactly what the credential
you called with may do, so a client reads its own limits once instead of
discovering them by collecting 403s. MCP callers get the same list from whoami.
That is not the same question as what a new token could carry: GET /v1/tokens
answers mintable_scopes, which is your own list minus the classes a self-minted
token may never hold, and empty when you lack write:account. Offer that one in a
scope picker, and use /v1/me to decide which calls to make.
Every error is an RFC 9457 problem
document, served as application/problem+json:
{
"type": "https://developerz.ai/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "repo not found",
"instance": "/v1/repos/acme/widgets",
"request_id": "4f0c2b8e-2b3a-4d1f-9a6e-6f6a0c2b8e4f"
}
Switch on type, never on title: the slug is a stable contract, the title is
prose. detail explains this particular occurrence, instance is the path, and
request_id is the correlation id described below. Some errors add extension
members: errors carries flattened field errors on a validation failure, and
retry_after carries seconds on a 429.
HTTP status codes follow REST conventions: 200 OK, 201 Created,
400 Bad Request (validation), 401 Unauthorized, 403 Forbidden (scope),
404 Not Found, 429 Too Many Requests, 500 Internal Server Error.
Request ids
Every response carries X-Request-Id, and the same value is the request_id
member of any problem body. It is the id your call was logged under, so it is the
one string worth quoting when something goes wrong. There is no answer without
one: successes, refusals, the unauthenticated endpoints and a 404 for a path that
matches no route all carry it.
Send your own and it is adopted verbatim:
curl -i https://api.developerz.ai/v1/repos \
-H "Authorization: Bearer dev_pat_…" \
-H "X-Request-Id: ci-run-4711"
A value that is not printable ASCII within 200 characters is not adopted, and one
is minted instead. That is deliberately not a 400: this header is often stamped
by a proxy you do not control, and a diagnostic must never fail the call it is
diagnosing. Nothing is hidden by that, because the id actually used comes back on
the same response. So the rule to build on is the narrow one: read the response
header to learn which id was used. Log it next to your own call, and a support
request becomes one lookup instead of a guess at a timestamp.
Rate limiting
Limits are per token or session, not per account: 60 reads and 10 writes per
minute, plus a 300-per-minute per-IP bucket applied before your credential is
even resolved.
Every /v1 response the guard produces carries RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset (seconds, not a timestamp) for the
bucket that request was charged against, so a client budgets itself instead of
discovering the cap by being throttled. The two publicly cacheable /v1
documents that sit ahead of the guard, GET /v1/openapi.json and the public OSS
stats badge, send no such headers: a per-caller counter behind a shared cache
would be a wrong number for everyone reading it. Absent headers on those two are
the contract, not a protocol failure. A 429 is an RFC 9457 problem document and adds retry-after,
the same seconds as RateLimit-Reset. The full contract, including how MCP calls
are classified, is on rate limits.
Do not poll a run at 1 Hz. Sixty reads a minute IS the read budget, and an
agent run can last hours. GET /v1/tasks/:id/events takes ?wait_ms= (up to
25000) and holds the request open until the first new event lands, so watching a
run costs about 4% of your budget instead of all of it. The MCP task run_tail
verb takes the same argument. See
watching a run.
Idempotency
Every POST, PUT, PATCH and DELETE under /v1 accepts an
Idempotency-Key header, uniformly across every write endpoint, not opt-in
per route. Send one on a call your client might retry (a phone app, a CI
script, a flaky connection):
curl -X POST https://api.developerz.ai/v1/tasks \
-H "Authorization: Bearer dev_pat_…" \
-H "Idempotency-Key: 8e6a6c0e-1f2e-4b6a-9c3a-2e7a6c0e1f2e" \
-H "Content-Type: application/json" \
-d '{"title": "Fix flaky test"}'
- The first request with a key runs once and caches the response.
- A retry with the same key and body replays that cached response
(
X-Idempotent-Replayed: true) instead of running it again.
- The same key with a different body is a
409 Conflict, a client bug.
- Omit the header and the call goes through unconditionally, every time.
Keys are scoped to the caller, so two tokens sharing the same string never
alias each other's responses. Routes that hand back a one-time secret (minting
a token, enrolling a runner) never cache their response, so a retry always
mints a fresh credential rather than replaying the first one.
SDK
The OpenAPI 3.1 document is published at
https://api.developerz.ai/v1/openapi.json
and is accessible without authentication. Generate a client in any language that
supports OpenAPI tooling. For an agent, the MCP interface
(/docs/mcp-verbs) is the better target because it carries
schema validation and disclosures by design. A REST client is appropriate for
scripts and CI workflows.