[ 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. /v1/chat/… is the one exception: the chat widget calls it from browsers, with its own authentication and no API keys.
  • 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
signals:read custom-event counts, top values, follow-ups; watches and their firings V1+, with the signals add-on
signals:write create a watch as a draft (a person confirms it on the site) V1+, with the signals add-on
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.
  • The signals scopes need the signals add-on, on either edition. A key may be created with them at any time; while the tenant has no add-on, every signals route answers 403 {"error":"Custom signals need the signals add-on; an owner can add it at https://developerbench.com/app/<slug>."} and the signals tools are not listed. They work as soon as it is added.

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.

Suspended tenants

A tenant a platform admin has suspended is off here too, billed or operator tenant alike. Every /v1 route including GET /v1/me answers

[ text ]
403 {"error":"This account is suspended. Contact us from developerbench.com/contact."}

and every request to the MCP endpoint (initialize, tools/list and tools/call alike) gets the same HTTP 403 and body, before any JSON-RPC. The tenant's status is read fresh on every request (no cache), so it takes effect on the next request and ends on the first request after a resume. Its keys stay valid (not 401) and are not revoked; nothing is audited, and the reason is never shown.

Suspension wins over billing. Suspending cancels a billed tenant's subscription, so it is also not entitled; the suspension is checked first and the answer is the 403, never the 402. After a resume, a billed tenant that isn't entitled yet gets the 402 as usual.

The status and the sentence are the ones the account area answers; ingest answers the same status with the machine body {"error":"suspended"}.

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 tenant is suspended (every route, GET /v1/me included; "Suspended tenants" above), or the key lacks the route's scope, or the scope needs V2 and the tenant is on V1, or a signals route without the signals add-on. The message names the suspension, or the missing scope, edition or add-on
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) → suspended (403) → entitlement (402) → scope, edition and add-on (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 (every POST route and its tool), 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>
watch.create watch:<id>

Reads of other scopes are not audited. A request that fails before the route runs (401–429) is not audited, nor one the route refuses (400, 404): a row is written for each answered read.

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; not while suspended)

[ 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, and minus the signals scopes while the tenant has no signals add-on.

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), or Untriaged <kind> (fingerprint <id>) when its sensor sent none. The same holds for the title of release impact rows.

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 a flush naming the release first reached Dev Bench from that environment. A release reported only before that was recorded falls back to the earliest sighting Dev Bench holds (a fingerprint first seen in it, or a fingerprint or sensor last seen on it), which is never earlier than the release appeared and can be later. Recording new releases is rate-limited per tenant (50 an hour); a release past that also falls back.
  • 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. An identity seen in two environments is two rows (one per environment); reports sums its fingerprints' reports there. 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, at most 100), newest uploaded_at first. 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, straight to the object store (the bytes never pass through the API); ask again for a fresh one. expires_at is when it stops working (to the second, never later than the store's own expiry). An empty data means no bundle has been uploaded. 503 {"error":"Evidence downloads are not available right now; try again later."} when the API has no object store configured (not audited).

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 (no App installation and no stored token, or an installation GitHub reports uninstalled or suspended): {"error":"This account has no working GitHub connection, so Dev Bench cannot act on its GitHub issues; an owner can connect GitHub at https://developerbench.com/app/<slug>."}. The issue's own GitHub issue (the repo and number it was filed as) is acted on, whichever control repo the tenant uses now.
  • 502 {"error":"GitHub did not accept the change, so nothing was changed; try again later."} when GitHub fails or refuses (an error status, rate limited, the issue gone, the credential without access to the repo): nothing changed on our side, nothing audited. The calls are made in the order below and stop at the first failure, so a comment GitHub accepted before a refused close stays on GitHub (a retry posts it again).
  • 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. "Already true" is our state (above). Writes to one issue are handled one at a time, so concurrent identical writes reach GitHub once.
  • Every comment Dev Bench posts names the key: <text>\n\n— via the Dev Bench API, key "Sam's Claude".
  • Bodies are JSON objects; an empty body is {}; a field the route doesn't take is 400 (unknown field "<name>"; the fields are …). Text is trimmed. Text limits: comment, reason, body ≤ 60,000 characters; fixed_in 1–255 characters, no control characters or backticks (it is set in a code span). Empty or whitespace-only required text is 400 (reason is required, reason must not be empty; likewise body); an empty optional comment is left out.
  • Order: the body (400) → the issue (404, as the reads) → not filed (409) → already true (200, changed: false) → credential (409) → GitHub (502) → our state and the audit row, together → 200.
  • A change GitHub accepted but we failed to record (a 500 after the GitHub call) is not lost: before a resolve, dismiss or reopen is sent to GitHub, the issue is marked for reconciling, and recording the change clears the mark. A mark still set five minutes later makes the next issue sync read the GitHub issue back and set our state from it (an open issue on a dismissed problem is reopened, as below), with or without new activity. A retry by the client also records it.
  • Over 30 writes a minute: 429 {"error":"Too many writes for this key; wait and retry (see Retry-After)."}. Reads are unaffected.

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; no comment when neither is), then close with state_reason: completed resolved / stage resolved (first resolution time kept)
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; resolution time cleared)
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 on its own: a dismissed problem's issue that stays closed costs no GitHub call. It comes back only through a reopen this API recorded, the reconcile mark above, or the App's issues webhook reporting a reopen made on GitHub. A reopened issue then follows the normal rules: a person or the builder decides what happens next.

Custom signals (signals:read, signals:write; the signals add-on)

The client's own events, sent with the SDKs' track call, and the watches that alert on them. Every route needs the signals add-on (403 without it, as under "Scopes").

  • Counts are weighted: each is the sum of the events' weights. A process that sampled (over 1,000 events a minute) sends exact totals per event name, so a name's counts are exact; counts by a field's value are then estimates.
  • Windows: period is 24h, 7d or 30d. 24h is 24 hourly buckets ending with the current UTC hour (from/to and bucket starts are RFC 3339 hours); 7d and 30d are UTC days as "Counts and time windows" (from/to and starts are YYYY-MM-DD). Series are oldest first and zero-filled.
  • Raw events are kept 30 days, aggregates for as long as the account exists. A breakdown by a field that no watch groups by is computed from raw events, so the oldest day of a 30d breakdown may be partial.
  • An event name in a path or query is [a-z0-9_.]{1,64} and a field [a-z0-9_]{1,64} (400 otherwise). A name with no events is not a 404: it answers zeros, because "nothing happened" is an answer.
  • A field value in a response is the JSON value the app sent (string, number or boolean, after redaction), or null for events without that field.
  • No identity: these routes never return the users on events, only counts. (Identity stays where users:read guards it.)

GET /v1/signals/events/{name}/counts (signals:read)

Query: period (default 24h), environment (default production), by (optional field). With by: the 10 values with the largest total, each with its own series, and other for the rest (events without the field count as the value null).

[ json ]
{
  "name": "add_to_cart", "environment": "production", "period": "24h",
  "bucket": "hour", "from": "2026-10-07T15:00:00Z", "to": "2026-10-08T14:00:00Z",
  "total": 1890,
  "series": [{"start": "2026-10-07T15:00:00Z", "count": 71}, "…"],
  "by": "sku",
  "groups": [
    {"value": "A-123", "total": 412, "series": [{"start": "2026-10-07T15:00:00Z", "count": 9}, "…"]}
  ],
  "other": {"total": 1478, "series": ["…"]}
}

Without by, by, groups and other are null. to is the start of the last bucket.

GET /v1/signals/events/{name}/top (signals:read)

Query: field (required), period (default 7d), environment, limit (1–100, default 25). The field's values ranked by count; ties by value. Not paginated.

[ json ]
{
  "name": "add_to_cart", "field": "sku", "environment": "production",
  "period": "7d", "from": "2026-10-01", "to": "2026-10-08",
  "total": 12840,
  "data": [{"value": "A-123", "count": 2304}, {"value": "B-456", "count": 1771}],
  "other": 8765
}

total is every event with that name in the period; other is total minus the listed counts (events without the field included).

GET /v1/signals/followups (signals:read)

"Added to cart, never purchased within 24 hours, by SKU." Query:

Parameter
first, then event names (required; may be equal)
by optional field; when given, a then must carry the same value
match who must do the then: session (default: the same browser session, from the trace), user (the same email, else the same account), any
within <n>m, <n>h or <n>d, 1 minute to 7 days; default 24h
period which first events count, by their time; default 7d
environment, limit as above (limit 1–100, default 25)

A first event is followed when a matching then happens at or after it and within within; pending when not (yet) followed and within has not elapsed; missing otherwise. Rows are values of by (one row, value: null, without it), ranked by missing, then value. Not paginated.

[ json ]
{
  "first": "add_to_cart", "then": "purchase", "by": "sku", "match": "session",
  "within": "24h", "environment": "production", "period": "7d",
  "from": "2026-10-01", "to": "2026-10-08",
  "totals": {"first": 1890, "followed": 1210, "missing": 610, "pending": 70, "skipped": 0},
  "data": [{"value": "A-123", "first": 120, "followed": 30, "missing": 85, "pending": 5}]
}
  • In every row and in totals, first = followed + missing + pending (weighted).
  • skipped: first events that cannot be matched and are left out of the rows: no trace (match=session) or no user (match=user).
  • Computed from raw events (30 days). Where a process sampled, a kept first whose then was sampled out reads as missing: treat sampled windows as estimates.

The Watch object

[ json ]
{
  "id": 12,
  "text": "Tell me when add-to-cart failures for any SKU spike",
  "state": "active",
  "revision": 2,
  "compile": {"state": "done", "error": null, "compiled_at": "2026-10-08T14:01:09Z"},
  "description": "Every hour, for each SKU: count add_to_cart events whose request failed; alert when it is over 4× the usual for that hour of the week.",
  "detector": {"shape": "spike", "…": "…"},
  "backtest": {
    "from": "2026-10-01T14:00:00Z", "to": "2026-10-08T14:00:00Z", "firings": 3,
    "groups": [{"group": {"sku": "A-123"}, "firings": 2}, {"group": {"sku": "C-9"}, "firings": 1}],
    "ran_at": "2026-10-08T14:01:12Z"
  },
  "outputs": {"issue": true, "slack": true, "email": true, "report": true},
  "recipients": null,
  "created_by": {"kind": "api_key", "id": 7, "name": "Sam's Claude"},
  "created_at": "2026-10-08T14:01:02Z",
  "confirmed_by": {"login": "sam", "name": "Sam Lee"},
  "confirmed_at": "2026-10-08T14:20:44Z",
  "paused_at": null,
  "last_fired_at": "2026-10-08T15:05:00Z",
  "firings_7d": 1
}
  • state: draft (written, not running), active, paused. Only a person confirms a draft, on the site; at most 25 active per tenant.
  • revision: increments whenever the text, and so the detector, changes.
  • compile.state: pending (a draft created here is compiled in the background, normally within a minute; it waits for the next UTC day when the tenant's daily AI budget is spent), done, or failed (error is the compiler's sentence saying what was unclear; the text is edited on the site).
  • description: the detector in plain words, as the person confirms it; null until compiled.
  • detector: the compiled detector, null until compiled. Only shape (threshold, spike, absence, missing_followup) is promised; the rest belongs to the compiler and may change. Show description.
  • backtest: what the detector would have fired over the 7 days before ran_at, or null until run.
  • outputs: what a firing does: file or update a GitHub issue, a Slack message (sent only when the tenant has a Slack webhook), an email to recipients, and a line in the weekly report.
  • recipients: null means the tenant's owners at send time; otherwise up to 20 email addresses.
  • created_by / confirmed_by: {"kind":"person","login","name"} or {"kind":"api_key","id","name"} for created_by, {"login","name"} for confirmed_by; null once that account or key is gone, or (for confirmed_by) never confirmed.
  • firings_7d: firings in the last 7 days.

GET /v1/signals/watches (signals:read)

Query: state (all default, draft, active, paused), limit, cursor. Newest first.

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

GET /v1/signals/watches/{id}/firings (signals:read)

The watch's firings, newest first. Query: limit, cursor. 404 if the watch is unknown or another tenant's.

[ json ]
{"data": [
  {"id": 5521, "fired_at": "2026-10-08T15:05:00Z",
   "window": {"from": "2026-10-08T14:00:00Z", "to": "2026-10-08T15:00:00Z"},
   "group": {"sku": "A-123"}, "observed": 38, "expected": 9.5,
   "summary": "38 add_to_cart events for sku A-123 failed between 14:00 and 15:00 UTC; usually 9.5.",
   "issue": {"id": 431, "github": {"number": 92, "url": "https://github.com/acme/devbench-issues/issues/92"}},
   "alerted": true}
], "next_cursor": null}
  • group: the grouping field and value, or null for a watch without one.
  • observed and expected by shape: threshold: the value and the threshold; spike: the value and the usual level for that hour of the week; absence: the count in the window and the usual count; missing_followup: the missing count and the threshold.
  • issue: the issue this firing filed or updated (github null until filed), or null when the watch's issue output is off.
  • alerted: whether a Slack or email alert went out for it (alerts go out at most once per watch per hour).

POST /v1/signals/watches (signals:write; audited; a write)

[ json ]
{"text": "Tell me when add-to-cart failures for any SKU spike",
 "recipients": ["sam@acme.com"], "outputs": {"slack": false}}

201 with the Watch: state: "draft", compile.state: "pending".

  • Drafts only: nothing runs until a person confirms it on the site. There is no API route to confirm, edit, pause or delete a watch: agents draft, people confirm.
  • text: 1–2,000 characters after trimming (400 otherwise). recipients (optional): null or 1–20 valid email addresses. outputs (optional): any of the four booleans; the rest default to true.
  • 409 {"error":"This account has 100 watches; delete some on the site first."} at 100 watches in any state: drafts nobody confirms must not pile up without bound.
  • Compiling counts against the tenant's daily AI budget.
  • Not idempotent: two calls make two drafts.

Generated from the API contract when the site is built.