# API overview 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`](https://shruwd.io/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. ```bash 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. ```json { "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](https://shruwd.io/docs/getting-started/reading-your-results.md). ## Errors The response body **is** the error. There is no wrapper. ```json { "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](https://shruwd.io/docs/api/typescript-sdk.md) does this for you. ## Times and ranges Timestamps are ISO 8601 UTC. Days are `YYYY-MM-DD`. > **Note** > > 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](https://shruwd.io/docs/api/brands.md) — create and manage what you measure - [TypeScript SDK](https://shruwd.io/docs/api/typescript-sdk.md) — a typed client over all of this - [For AI agents](https://shruwd.io/docs/api/for-ai-agents.md) — machine-readable docs