Skip to content

REST API

Create an API key, send it as a bearer token, and call the REST API at the Base URL shown on the API keys page — https://<your-project>.supabase.co/functions/v1/api/v1. Start with GET /me. Reads need the Read scope, edits need Write, and verification takes the two verification scopes. Nothing anywhere names a Project: the key does that.

GET /me is the one endpoint that needs no scope, because a client is always allowed to discover what it can do:

Terminal window
curl https://<your-project>.supabase.co/functions/v1/api/v1/me \
-H "Authorization: Bearer adk_live_<your-key>"

It answers with the Workspace and the one Project the key acts in, and the key’s own name, prefix, scopes and expiry. If this works, the credential is good: every 401 after it is about the key, and every 403 is about a scope the key was not given.

There is no projectId path segment, query parameter or body field anywhere in this API — sending one is refused rather than ignored. A key names exactly one Project, and every short id is resolved inside it, so another Project’s Scenario simply reads as a plain 404.

A script that spans two Projects holds two keys. That is not a limitation worked around: a Scenario’s path, its chains, its nested children and its verification passes all live inside one Project, so there is nothing for a single credential to do across two.

All four of these need Read:

  • GET /scenarios — the Project’s Scenarios, paginated with limit and offset. Filter with lifecycleStatus=draft|published|deprecated and verificationStatus=not_attempted|fit|gap (repeatable), and search with q. Deleted drafts are never included, and deprecated Scenarios only when asked for by name.
  • GET /scenarios/{shortId} — one Scenario in full: its ordered path of conditions and results with their evidence, the latest closed verification pass, and one-hop neighbours on all three relationship axes. The response’s ETag is the Scenario’s version — keep it.
  • GET /scenarios/{shortId}/text — the same Scenario rendered as plain text, for dropping into a prompt. A one-directional rendering: never parsed back, never a storage format.
  • GET /scenarios/deleted — soft-deleted drafts, the one read that sees them, so a client can offer restore.

Every Scenario belongs to exactly one Feature, and a Scenario listing names its Feature as a string. These two reads are where that string comes from, both on Read:

  • GET /features — every Feature in the Project, each with its name, slug, whether it is cross-cutting, and how many live Scenarios it holds. Deprecated Scenarios are counted; deleted ones never are. There is no pagination, because a Project’s Features are a short list you read whole.
  • GET /features/{slug} — one Feature with the Scenarios it gathers, in two lists that stay apart: scenarios, the ones that belong to it, and usedByScenarios, the ones that declare it as cross-cutting.

A Feature is named by its slug, the same value the app’s own URLs use — Features have no short id.

POST /scenarios creates one, with Write:

Terminal window
curl -X POST https://<your-project>.supabase.co/functions/v1/api/v1/scenarios \
-H "Authorization: Bearer adk_live_<your-key>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f1c-checkout-empty-cart" \
-d '{
"title": "Empty cart shows the browse prompt",
"placeName": "Checkout page",
"vantage": "product",
"featureName": "Cart",
"conditions": [{ "text": "The shopper has no items in their cart" }],
"results": [{ "text": "The page shows the browse prompt instead of the order summary" }]
}'

It lands as a real, queryable row immediately — lifecycle Draft, verification Not attempted — and the Location header points at it in the app. There is no staging area between a proposal and a Scenario; a human publishes it when it is ready.

Conditions and results are plain text at creation. Evidence and chain links are added afterwards by editing, because a caller writing a Scenario has not observed anything to attach yet.

The rest of the write surface:

  • PATCH /scenarios/{shortId}title, driftNote, parentShortId, or path. Needs If-Match.
  • POST /scenarios/{shortId}/lifecycle{"status": "published"}. Lifecycle only: Draft ↔ Published, Published → Deprecated, Deprecated → Published.
  • DELETE /scenarios/{shortId} — drafts only, and soft. A published Scenario is deprecated instead and can never be deleted, because a version stamp cites it. Refused while another Scenario chains from one of its results or it has live nested children, each refusal naming what is in the way; ?force=true clears those links and re-parents the children.
  • POST /scenarios/{shortId}/restore — brings a soft-deleted draft back.

path on a PATCH is a full replacement of the ordered conditions and results, not a patch of it: an item left out is deleted, and position must run 1..n contiguously across conditions and results together, because they are one sequence — a condition may sit after a result as a mid-path checkpoint.

Send each item’s id from the GET to keep it (which is what preserves chain links and evidence across an edit), and omit id for a new one. evidence is required on every item: omitting it means “this item has no evidence” to the storage layer, which is never what someone renaming a single line intends, so the API makes you say [].

A pass is one attempt at checking a Scenario against reality. A Scenario can have at most one open at a time, and its findings attach to the individual path steps they are about.

  1. POST /scenarios/{shortId}/verification-passes — opens one. Takes either verification scope. The response carries the path with each item’s id, so the caller can record findings without a second fetch. A second open pass is a 409 naming the one already there.
  2. POST /verification-passes/{passId}/steps — records a finding. With an itemId it is a per-item path step and needs Record path-step findings; without one it targets the pass-level bucket and needs Record verification outcome. Everything is optional: a step with no outcome, note or evidence is valid and recorded. Omitting evidence leaves that step’s existing blocks alone; [] clears them.
  3. POST /verification-passes/{passId}/close{"outcome": "fit"} or {"outcome": "gap"}. This is the only call in the whole API that moves a Scenario’s verification status. Not attempted is the absence of a check, not a result you can report.
  4. POST /verification-passes/{passId}/abandon — frees the slot without touching verification status, which is what a pass that was started and never finished should leave behind.

That split is the point of having two verification scopes: a CI harness holding only Record path-step findings can report everything it saw and still cannot declare a Scenario a fit, and a key holding only Record verification outcome can rule on a pass without rewriting the findings underneath it.

A pass never touches lifecycle status, and lifecycle never touches verification. The two axes are independent everywhere, this API included.

GET /scenarios/{shortId} returns an ETag — the Scenario’s version, quoted. PATCH requires it back as If-Match:

Terminal window
curl -X PATCH https://<your-project>.supabase.co/functions/v1/api/v1/scenarios/a1b2c3 \
-H "Authorization: Bearer adk_live_<your-key>" \
-H "If-Match: \"7\"" \
-H "Content-Type: application/json" \
-d '{ "title": "Empty cart shows the browse prompt" }'

No If-Match is a 428; a stale one is a 412 naming the current version, so the fix is always the same — fetch the Scenario again, re-apply your change, retry. The header is authoritative: an expectedVersion in the body is refused rather than honoured, so there is only ever one place the version can come from.

Any write takes an Idempotency-Key header. Replaying a create with the same key returns the original Scenario with 200 and Idempotency-Replayed: true instead of creating a second one — so a runner that times out and retries cannot leave duplicates behind.

Like the version, it is a header and only a header: an idempotencyKey in the body is refused.

Every error has the same shape:

{
"error": {
"code": "missing_scope",
"message": "This key does not hold the scope this request needs.",
"details": { "required": ["scenarios:write"] }
},
"requestId": "01J..."
}

requestId is in the body as well as the X-Request-Id header, because a support conversation usually starts from a pasted response rather than a captured header. Quote it.

Status What it means
400 validation The request body or a parameter is wrong; details names the field.
401 invalid_api_key, api_key_revoked, api_key_expired The key is missing, unknown, revoked or past its expiry.
402 plan_limit_exceeded This Workspace’s plan does not include the API.
403 missing_scope The key is good; it was not given this scope.
404 not_found No such Scenario in this key’s Project — including one that exists in another Project.
409 pass_already_open This Scenario already has an open verification pass.
412 version_conflict Your If-Match is stale; the response names the current version.
428 precondition_required A PATCH arrived without If-Match.
429 rate_limited Retry after the seconds in Retry-After.
503 auth_unavailable Key verification is down. Deliberately not a 401 — a broken verifier is not a bad credential, and retrying is the right response.

Per key: 600 reads a minute and 60 writes a minute, counted separately, plus 600 requests a minute per client address. Creating Scenarios and opening verification passes carry their own slower Workspace-wide limits, shared with MCP, so a runaway agent cannot fill a Project.

Every 429 carries Retry-After in seconds. Honour it rather than spinning: the limits are durable, so retrying early only spends the next window.

GET /openapi.json serves the OpenAPI 3.1 document, unauthenticated — tooling fetches a contract before it holds a key. It is generated from the same route table the server dispatches on, including each route’s required scopes, so it cannot drift from what the API actually does.

Point a client generator at it rather than hand-writing request types. The OpenAPI document button on the API keys page opens it for your Workspace’s deployment.

  • No key management. No endpoint creates, lists or revokes a key, because there is no admin scope and no wildcard. A leaked key cannot mint itself a replacement. Key management is a human, admin-only action in the app.
  • No hard delete. Drafts tombstone and can be restored; published Scenarios deprecate.
  • No Feature authoring. Features are readable, not writable: one is created by naming it in featureName when you create a Scenario, and renamed or marked cross-cutting in the app.
  • No cross-Project call. See above — hold one key per Project.
  • No verification status setter. Closing a pass is the only way that column moves, so every Fit and Gap has an attempt behind it.

Every write through this API is attributed to the key in the Scenario’s history, distinct from a change made by a person in the app or by an agent over MCP.