[ api ]

HTTP API v1

Programs holding a Dev Bench API key can read your issues, error counts, trends, release impact, affected users and logs, and on V2 resolve, dismiss, reopen and comment on issues. An owner creates keys on the account page. AI assistants can use the same operations as tools through the MCP server.

Host and transport

  • https://api.developerbench.com. /v1/… is the HTTP API; /mcp is the MCP endpoint. Nothing else is served on this host.
  • HTTPS only. JSON in and out (Content-Type: application/json); request bodies with unknown fields are 400. Every response is Cache-Control: no-store.
  • No cookies, no CORS headers: callers are programs holding a key, not browsers. The cookie-session routes on developerbench.com/api/… are unchanged and do not accept API keys.
  • All times are UTC, RFC 3339 (2026-10-08T14:03:11Z); dates are YYYY-MM-DD UTC days.
  • While the API is switched off for maintenance, every request answers 503 {"error":"The Dev Bench API is temporarily disabled."}.

Authentication: API keys

[ text ]
Authorization: Bearer dbk_5n3k7q2x…
  • Format: dbk_ + 32 random bytes in lowercase base32, unpadded (dbk_ + 52 characters). Shown once, at creation. Only its SHA-256 is stored, plus the first 8 characters (dbk_5n3k) for display.
  • Sent only in the Authorization header. A key in a query string is never read (the request is 401 as if no key were sent).
  • A key belongs to one tenant, not to a person: it keeps working after the account that created it leaves. Every audited action is recorded against the key's id and name.
  • Keys are created, listed and revoked by tenant owners on the account page.
  • last_used_at is updated at most once a minute per key.

Scopes

Scope Grants Edition
issues:read list/read issues, triage text, GitHub links V1+
sensors:read fingerprints, counts, trends, release impact V1+
users:read affected users' emails / account ids V1+
logs:read log slices and evidence bundles V1+
issues:write resolve, dismiss, reopen, comment V2
prs:write reserved. Review and merge of builder PRs by the tenant's own agents. Its routes and tools are not available yet; until then the scope cannot be granted V2
  • Default scopes at creation: issues:read, sensors:read.
  • Editions come from the tenant's edition: v1 → V1; v2 and pilot (operator pilots and engagements) → V2.
  • A V1 tenant cannot create a key with a V2 scope (403 at creation). A key that holds a V2 scope while its tenant is on V1 (the tenant moved from V2 to V1) keeps the scope on record but every V2 route answers 403 and the V2 tools are not listed; they work again if the tenant returns to V2.

Entitlement (billing)

A billed tenant that is not entitled gets 402 on every route except GET /v1/me, and every MCP tools/call returns the same error. Operator tenants (not billed) are always entitled.

Errors

Every error body is {"error": "<a sentence a person can act on>"}.

Status When
400 malformed JSON, unknown body field, invalid query parameter or value (the message names it)
401 no key, malformed key, unknown key, revoked key. Always {"error":"Invalid or revoked API key."}: the response does not say which
402 billed tenant not entitled ({"error":"Billing for this account has lapsed; an owner can fix it at https://developerbench.com/app/<slug>."})
403 the key lacks the route's scope, or the scope needs V2 and the tenant is on V1. The message names the missing scope or edition
404 no such resource or one belonging to another tenant: the two are indistinguishable, byte for byte
409 a write on an issue that was never filed to GitHub, or the tenant has no usable GitHub credential
429 rate limited; Retry-After: <seconds>
502 GitHub failed or refused a write. Nothing was changed on our side
503 API disabled

Order of checks: key (401) → disabled (503) → entitlement (402) → scope and edition (403) → rate limit (429) → the route itself (400/404/409/…).

Rate limits

Per key, sliding one-minute windows:

  • 120 requests/minute (all routes and MCP calls, writes included);
  • 30 writes/minute (the four POST routes and their tools), on top.

Over either limit: 429 with Retry-After. Each MCP tools/call counts as one request (a write tool also as one write); initialize, tools/list and notifications do not count.

Pagination

List routes take limit (1–100, default 25; outside that range is 400) and cursor (opaque; from the previous response, with the same filters). They answer:

[ json ]
{"data": [ … ], "next_cursor": "eyJs…" }

next_cursor is null on the last page. A cursor from a different route or different filters is 400.

Counts and time windows

Counts come from per-day buckets (UTC days), so windows are whole days:

  • last_24h: yesterday and today (UTC), so it covers at least the last 24 hours;
  • last_7d / 7d: today and the 7 days before it; 30d: today and the 30 days before it;
  • month: the current calendar month (UTC) to date.

Responses that use a window say which days it covered (from, to).

Auditing

These append an api_audit row (tenant, key, action, target, time), retained while the tenant exists and deleted with it:

Action Target
issue.resolve, issue.dismiss, issue.reopen, issue.comment issue:<id>
users.lookup email or account: which kind of lookup, not the value, so deleting a person's data leaves no copy of the address behind
issue.users, issue.logs, issue.evidence issue:<id>

Reads of other scopes are not audited. A request that fails before the route runs (401–429) is not audited.

Objects

Issue

An issue is a Dev Bench problem (a cluster: one root cause, however many fingerprints report it). id is ours; github is the GitHub issue it was filed as, or null while it has not been filed (still triaging, or held for a human).

[ json ]
{
  "id": 412,
  "title": "Checkout fails for carts with a gift card",
  "summary": "Customers paying with a gift card see \"Something went wrong\" …",
  "kind": "error",
  "state": "open",
  "stage": "needs_human",
  "impact": 37,
  "confidence": 0.82,
  "environments": ["production", "staging"],
  "counts": {"total": 1893, "last_24h": 41, "last_7d": 388},
  "first_seen_at": "2026-09-30T08:12:40Z",
  "last_seen_at": "2026-10-08T13:58:02Z",
  "regressions": 1,
  "github": {"number": 87, "url": "https://github.com/acme/devbench-issues/issues/87"}
}
  • kind: error, silent_failure, frustration, degradation, log_template, handled_failure.
  • state: open, resolved (closed as done), dismissed (closed as not planned). This is GitHub's state as last synced or written through this API.
  • stage: where the work is: triaging, needs_human, ready_to_fix, fixing, in_review, ready_to_merge, gave_up, resolved, noise. New values may be added; treat unknown ones as open.
  • summary: the first paragraph of the triage text (≤ 500 characters).
  • impact: triage's estimate of affected users. confidence: triage's confidence in the root cause, 0–1.

Issue detail

The issue, plus:

[ json ]
{
  "diagnosis": "Customers paying with a gift card …\n\n**Root cause**\n\nThe gift-card branch …",
  "implicated": [
    {"repo": "acme/shop", "paths": ["app/checkout/payments.rb"], "confidence": 0.8}
  ],
  "fingerprints": [
    {"id": 9031, "kind": "error", "source": "server", "environment": "production",
     "total": 1702, "first_seen_at": "2026-09-30T08:12:40Z", "last_seen_at": "2026-10-08T13:58:02Z",
     "first_release": "2026.09.30-1", "last_release": "2026.10.07-2"}
  ],
  "affected_users": {"emails": 29, "accounts": 31, "unlisted": 0}
}
  • diagnosis: the full triage text (Markdown, root cause included).
  • affected_users: distinct identities sensors listed; unlisted is how many more they saw but did not list, so the true count is at least the larger of emails/accounts and may be up to that plus unlisted.

Routes

GET /v1/me (any key, works while billing has lapsed)

[ json ]
{
  "tenant": {"slug": "acme", "name": "Acme Shop", "edition": "v2"},
  "key": {"id": 7, "name": "Sam's Claude", "prefix": "dbk_5n3k",
          "scopes": ["issues:read", "sensors:read", "issues:write"]},
  "usable_scopes": ["issues:read", "sensors:read", "issues:write"],
  "entitled": true
}

usable_scopes is scopes minus what the edition does not allow.

GET /v1/issues (issues:read)

Query: state (open default, resolved, dismissed, all), environment (issues with any fingerprint in it), kind, since (RFC 3339: last seen at or after), limit, cursor. Newest last_seen_at first.

[ json ]
{"data": [ <Issue>, … ], "next_cursor": null}

GET /v1/issues/{id} (issues:read)

The issue detail. 404 if unknown or another tenant's.

GET /v1/errors/top (sensors:read)

Query: period (24h default, 7d, 30d, month), environment, kind, limit, cursor. Fingerprints ranked by their count in the period; ties by most recent last_seen_at. Fingerprints with no count in the period are left out.

[ json ]
{
  "period": "7d", "from": "2026-10-01", "to": "2026-10-08",
  "data": [
    {"fingerprint_id": 9031, "issue_id": 412, "title": "Checkout fails for carts with a gift card",
     "kind": "error", "environment": "production", "count": 371,
     "last_seen_at": "2026-10-08T13:58:02Z"}
  ],
  "next_cursor": null
}

issue_id is null for a fingerprint not yet triaged into an issue; title is then the fingerprint's own sample message (≤ 200 characters).

GET /v1/fingerprints/{id}/trend (sensors:read)

Query: days (1–90, default 30). One entry per UTC day, oldest first, zero-filled, ending today.

[ json ]
{"fingerprint_id": 9031, "issue_id": 412, "environment": "production",
 "days": [{"day": "2026-10-07", "count": 52}, {"day": "2026-10-08", "count": 41}]}

GET /v1/releases/{release}/impact (sensors:read)

{release} is the release string as the SDK reported it, URL-escaped. Query: environment (default production). 404 if no sensor in that environment has reported the release.

[ json ]
{
  "release": "2026.10.07-2", "environment": "production",
  "first_seen_at": "2026-10-07T16:20:00Z",
  "new": [
    {"fingerprint_id": 9120, "issue_id": 430, "title": "…", "kind": "error",
     "first_seen_at": "2026-10-07T16:41:09Z", "total": 64}
  ],
  "worse": [
    {"fingerprint_id": 9031, "issue_id": 412, "title": "…", "kind": "error",
     "per_day_before": 6.1, "per_day_after": 31.5}
  ]
}
  • first_seen_at: when the release was first reported in that environment.
  • new: fingerprints whose first sighting was in this release.
  • worse: older fingerprints whose average daily count over the days from the release's first day (up to 7, including today) is at least 2× the average over the 7 days before it, and higher by at least 5 a day.
  • Each list holds at most 100 entries, largest effect first (new by total, worse by per_day_after − per_day_before); not paginated.

GET /v1/users?email= or ?account= (users:read, audited)

Exactly one of the two (400 otherwise). Email matching is case-insensitive. One row per problem the person was affected by, most recent first. Paginated.

[ json ]
{"data": [
  {"email": "pat@example.com", "account": "acct_831", "environment": "production",
   "kind": "error", "reports": 4, "first_seen_at": "…", "last_seen_at": "…",
   "issue_id": 412, "title": "Checkout fails for carts with a gift card",
   "state": "open", "issue_url": "https://github.com/acme/devbench-issues/issues/87"}
], "next_cursor": null}

issue_id, title, state and issue_url are null when the fingerprint is not in an issue yet.

GET /v1/issues/{id}/users (users:read, audited)

The issue's affected users, one row per identity across its fingerprints, most recent first. Paginated.

[ json ]
{"data": [
  {"email": "pat@example.com", "account": "acct_831", "environment": "production",
   "reports": 4, "first_seen_at": "…", "last_seen_at": "…"}
], "unlisted": 0, "next_cursor": null}

Either email or account may be "", never both.

GET /v1/issues/{id}/logs (logs:read, audited)

The issue's answered log slices (already redacted by the sidecar and ingest), newest first. Paginated (by slice).

[ json ]
{"data": [
  {"fingerprint_id": 9031, "trace_key": "4bf92f3577b34da6", "status": "delivered",
   "lines": ["2026-10-08T13:58:01Z INFO POST /checkout …", "…"],
   "truncated": false, "answered_at": "2026-10-08T13:58:30Z"}
], "next_cursor": null}

status is delivered or empty (the sidecar held no line for that trace, which itself says the request may not have reached the server). Pending and expired requests are not listed.

GET /v1/issues/{id}/evidence (logs:read, audited)

Download links for the issue's uploaded evidence bundles (at most one per fingerprint). Not paginated.

[ json ]
{"data": [
  {"fingerprint_id": 9031, "uploaded_at": "2026-10-08T14:00:12Z",
   "url": "https://…", "expires_at": "2026-10-08T14:05:12Z"}
]}

url is a pre-signed GET valid for 5 minutes; ask again for a fresh one. An empty data means no bundle has been uploaded.

Writes (issues:write, V2; audited; 30/minute)

Writes go through GitHub. Each uses the tenant's GitHub credential (the App installation token, or the PAT of an older pilot) on the issue's GitHub issue; when GitHub accepts it, our issue is updated at once, so an immediate read agrees. The regular issue sync remains the authority.

  • 409 {"error":"This issue has not been filed to GitHub yet."} when github is null; 409 also when the tenant has no usable GitHub credential.
  • 502 when GitHub fails or refuses: nothing changed on our side, nothing audited.
  • Idempotent: a write whose effect is already true (resolving a resolved issue, reopening an open one) makes no GitHub call, answers 200 with "changed": false, and is still audited.
  • Every comment Dev Bench posts names the key: <text>\n\n— via the Dev Bench API, key "Sam's Claude".
  • Text limits: comment, reason, body ≤ 60,000 characters; fixed_in ≤ 255. Empty or whitespace-only required text is 400.

All four answer 200 {"issue": <Issue>, "changed": true|false}.

Route Body On GitHub Our state after
POST /v1/issues/{id}/resolve {"comment"?: "…", "fixed_in"?: "<release>"} comment (with "Fixed in <release>." when given), then close with state_reason: completed resolved / stage resolved
POST /v1/issues/{id}/dismiss {"reason": "…"} (required) comment with the reason, then close with state_reason: not_planned dismissed / stage noise
POST /v1/issues/{id}/reopen {"comment"?: "…"} reopen, then comment if given open / stage needs_human (from resolved or dismissed)
POST /v1/issues/{id}/comments {"body": "…"} (required) comment unchanged; always changed: true

Reopen sets the stage itself rather than waiting for the sync because the sync never revisits a dismissed issue (it would otherwise stay noise forever). A reopened issue then follows the normal rules: a person or the builder decides what happens next.

Generated from the API contract when the site is built.