Base URL, authentication, scopes, rate limits and the error shape shared by every Shruwd API endpoint.
Everything the dashboard does, the API does — it is the same surface, not a mirror of one.
https://shruwd.io/api/v1
Paths in these docs are relative to that base, as in the OpenAPI document: POST /brands
is POST https://shruwd.io/api/v1/brands.
JSON in, JSON out, UTF-8. The contract describes itself in OpenAPI 3.1 at
/openapi.json, which needs no key to read. The same document is also
served at /api/v1/openapi.json.
Authenticating
Mint a key in the dashboard under Account → API keys, then send it as a bearer token.
curl "https://shruwd.io/api/v1/workspace" \
-H "Authorization: Bearer $SHRUWD_API_KEY"
Keys can only be created from a signed-in session. A key uses the product; it can never administer the account — it cannot mint other keys, change billing, or delete anything that is not brand configuration.
Scopes
| Scope | Grants |
|---|---|
read | Every GET |
write | Brand, prompt and entity writes; finding transitions; manual cycles; ingest tokens |
default | Both |
A request outside its key's scopes is 403 insufficient_scope.
Roles
A request acts with your role in the workspace.
| Role | Can |
|---|---|
viewer | Every read |
editor | Prompts, competitors, finding transitions, brand name and timezone, run a cycle |
admin | The above, plus invites, connections and ingest tokens |
owner | The above, plus creating and archiving brands, and billing |
A key never exceeds its holder's role. Refusals are 403 not_a_member and
403 insufficient_role, the latter carrying details: { required, role }.
Workspaces are implicit
Your key determines the workspace. No path or body ever carries a workspace id.
If you own no workspace, your first POST /brands creates one. There is no workspace
resource to set up first.
A brand or finding id belonging to another workspace returns 404, not 403 —
indistinguishable from an id that does not exist.
Identifiers
Ids are ULIDs and opaque. Two human-readable handles sit beside them, and both are accepted wherever the id is:
| Handle | Used by | Example |
|---|---|---|
A brand's slug | {brandId} | /brands/waitlister |
A finding's number | {findingId} | /findings/7 |
Finding numbers start at 1 per workspace, in first-seen order, and are never reused.
Every metric carries its uncertainty
There is no shape in this API that returns a bare number.
{ "state": "ok", "point": 0.142, "lo": 0.081, "hi": 0.226, "n": 42 }
point, lo and hi are proportions from 0 to 1: 0.142 is 14.2%.
state is ok, insufficient_data (fewer than ten responses — not zero), or
undefined. See Reading your results.
Errors
The response body is the error. There is no wrapper.
{ "code": "plan_limit", "message": "…", "retryable": false, "details": { "limit": 25, "current": 25 } }
| Field | |
|---|---|
code | Stable and machine-readable. Branch on this, never on message |
message | Human-readable. May change at any time |
retryable | Whether retrying the identical request could succeed |
details | Present when there is something specific to say |
Common codes
| Code | Status | Meaning |
|---|---|---|
not_found | 404 | No such resource, or it belongs to another workspace |
insufficient_scope | 403 | The key lacks read or write |
not_a_member · insufficient_role | 403 | Your role is too low |
not_entitled | 403 | Your plan does not include this |
plan_limit | 409 | A plan cap is used up. details carries the numbers |
rate_limited | 429 | See below. retryable: true |
invalid_domain · invalid_name · invalid_timezone | 422 | Bad brand input |
duplicate_domain | 409 | Another active brand here has that domain |
duplicate_prompt | 409 | That prompt is already active on the brand |
context_terms_required | 422 | A short or common-word competitor name needs context terms |
domain_taken | 409 | Another entity of this brand owns that domain |
self_entity | 409 | The brand's own entity cannot be removed |
snapshot_taken | 409 | A free-plan domain has already had its one measurement |
workspace_on_hold | 409 | Measurement is paused pending review |
suggestion_decided | 409 | That suggestion was already accepted or dismissed |
empty_patch | 400 | A PATCH with no fields |
Rate limits
Per key, in fixed one-minute windows.
| Per minute | |
|---|---|
GET | 120 |
| Everything else | 30 |
Over the limit is 429 rate_limited with Retry-After in seconds. Honour it —
the SDK does this for you.
Times and ranges
Timestamps are ISO 8601 UTC. Days are YYYY-MM-DD.
Date ranges default to today in the brand's timezone, which is the calendar the measurements are bucketed on. UTC today can be a day ahead of it.
from is clamped to your plan's history window. When that happens the response says so
in historyFrom rather than failing.
Versioning
The version is in the path. Changes within v1 are additive only; anything breaking
would be /api/v2.
Next steps
- Brands — create and manage what you measure
- TypeScript SDK — a typed client over all of this
- For AI agents — machine-readable docs

