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.
Your first call
Section titled “Your first call”GET /me is the one endpoint that needs no scope, because a client is always
allowed to discover what it can do:
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.
The Project is never a parameter
Section titled “The Project is never a parameter”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.
Reading Scenarios
Section titled “Reading Scenarios”All four of these need Read:
GET /scenarios— the Project’s Scenarios, paginated withlimitandoffset. Filter withlifecycleStatus=draft|published|deprecatedandverificationStatus=not_attempted|fit|gap(repeatable), and search withq. 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’sETagis 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.
Reading Features
Section titled “Reading Features”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, andusedByScenarios, 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.
Writing Scenarios
Section titled “Writing Scenarios”POST /scenarios creates one, with Write:
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, orpath. NeedsIf-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=trueclears those links and re-parents the children.POST /scenarios/{shortId}/restore— brings a soft-deleted draft back.
Replacing a Scenario’s path
Section titled “Replacing a Scenario’s path”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 [].
Running a verification pass
Section titled “Running a verification pass”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.
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.POST /verification-passes/{passId}/steps— records a finding. With anitemIdit 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. Omittingevidenceleaves that step’s existing blocks alone;[]clears them.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.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.
Concurrency: ETag and If-Match
Section titled “Concurrency: ETag and If-Match”GET /scenarios/{shortId} returns an ETag — the Scenario’s version, quoted.
PATCH requires it back as If-Match:
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.
Retrying safely: Idempotency-Key
Section titled “Retrying safely: Idempotency-Key”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.
When a call is refused
Section titled “When a call 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. |
Rate limits
Section titled “Rate limits”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.
The contract itself
Section titled “The contract itself”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.
What this API deliberately does not have
Section titled “What this API deliberately does not have”- 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
featureNamewhen 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.