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;/mcpis 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 isCache-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 areYYYY-MM-DDUTC days. - While the API is switched off for maintenance, every request answers
503
{"error":"The Dev Bench API is temporarily disabled."}.
Authentication: API keys
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
Authorizationheader. 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_atis 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;v2andpilot(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
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
POSTroute 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:
{"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).
{
"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 asopen.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:
{
"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;unlistedis how many more they saw but did not list, so the true count is at least the larger ofemails/accountsand may be up to that plusunlisted.
Routes
GET /v1/me (any key, works while billing has lapsed; not while suspended)
{
"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.
{"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.
{
"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.
{"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.
{
"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 (
newbytotal,worsebyper_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.
{"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.
{"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).
{"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.
{"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."}whengithubisnull; 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 ourstate(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_in1–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; likewisebody); an empty optionalcommentis 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:
periodis24h,7dor30d.24his 24 hourly buckets ending with the current UTC hour (from/toand bucketstarts are RFC 3339 hours);7dand30dare UTC days as "Counts and time windows" (from/toandstarts areYYYY-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
30dbreakdown 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
nullfor events without that field. - No identity: these routes never return the users on events, only
counts. (Identity stays where
users:readguards 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).
{
"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.
{
"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.
{
"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:firstevents 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
firstwhosethenwas sampled out reads as missing: treat sampled windows as estimates.
The Watch object
{
"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, orfailed(erroris 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;nulluntil compiled.detector: the compiled detector,nulluntil compiled. Onlyshape(threshold,spike,absence,missing_followup) is promised; the rest belongs to the compiler and may change. Showdescription.backtest: what the detector would have fired over the 7 days beforeran_at, ornulluntil 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 torecipients, and a line in the weekly report.recipients:nullmeans 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"}forcreated_by,{"login","name"}forconfirmed_by;nullonce that account or key is gone, or (forconfirmed_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.
{"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.
{"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, ornullfor a watch without one.observedandexpectedby 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 (githubnulluntil filed), ornullwhen the watch'sissueoutput 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)
{"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):nullor 1–20 valid email addresses.outputs(optional): any of the four booleans; the rest default totrue.- 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.