API conventions
Every endpoint in the /v1 API follows these conventions. They are frozen per API version: a v2 would mount beside v1, never replace it.
Envelopes
Section titled “Envelopes”Requests and responses are wrapped in an envelope. Requests take { "data": … }; responses answer { "data": … } on success or { "error": { "code", "message" } } on failure, with meta where it adds something:
{ "data": { "id": "app_my-app", "name": "my-app" }, "meta": { "requestId": "7f3c…" }}meta carries requestId on every response (also in the X-Request-Id header; quote it when debugging), and nextCursor/hasMore on list responses.
Errors
Section titled “Errors”Failures share one shape and a fixed code→status mapping. Codes are stable and machine-readable; meanings never change within an API version.
| Code | Status | When |
|---|---|---|
malformed_request |
400 | Body is not JSON, or not an envelope |
invalid_key |
401 | Missing, unknown, revoked, or wrong-scope credential |
forbidden |
403 | Authenticated, but the role does not allow this action |
not_found |
404 | No such resource in your org |
conflict |
409 | E.g. re-registering a different payload under a used Idempotency-Key |
payload_too_large |
413 | Body over the 2 MB cap |
validation_failed |
422 | Well-formed envelope, invalid fields |
trait_validation_failed |
422 | A canonical trait failed its format validation |
event_denied |
422 | Event name is on the org deny-list |
trait_denied |
422 | Trait name is on the org deny-list |
over_quota |
429 | Org credit is exhausted: the billing hard stop |
rate_limited |
429 | Fixed-window rate limit; Retry-After is set |
dependency_unavailable |
503 | A downstream (AWS SES, Stripe, …) is unreachable |
internal |
500 | Anything else; the requestId identifies the log line |
400 vs 422 is deliberate: 400 means the request never parsed; 422 means it parsed but failed validation.
Pagination
Section titled “Pagination”Every list endpoint is cursor-paginated: offset paging would skip or duplicate rows under concurrent writes on the append-heavy collections.
- Request:
?cursor=<opaque>&limit=<n>(default 50, max 100). Many lists also takedirection=asc|desc; the sort key per list never changes, so cursors stay stable across the toggle. - Response:
meta: { "nextCursor": "…", "hasMore": true };nextCursorisnullwhen the list is exhausted.
Cursors are opaque: pass them back unchanged, never parse or construct them.
Naming and types
Section titled “Naming and types”/v1prefix on every route.- Stripe-style prefixed IDs:
usr_,evt_,idn_,seg_,src_,dst_,jrn_,dlv_,vio_,qtn_,dom_,sup_,run_,top_, so an ID tells you its resource in logs, URLs, and payloads. The one exception: app ids are readable slugs (app_storefront), unique per org, never re-slugged on rename. Source ids (src_) are TypeIDs like the rest. - ISO 8601 UTC timestamps, camelCase JSON fields, kebab-case URLs.
Environments
Section titled “Environments”Dashboard API endpoints read X-Cow-Environment: development|production into the auth context: absent means production, and any other value is a 400 malformed_request (a typo must never silently read production). Every scoped list, detail, and settings call then answers for that environment only. Ingestion endpoints ignore the header: the source a write names belongs to one app, and that app’s environment decides. See Environments.
Authentication
Section titled “Authentication”Two credential types, two subtrees; they never cross:
- Ingestion (
/v1/identify,/v1/track,/v1/batch):Authorization: Bearer <org API key>, a Clerk API key whose subject is your organization. Org admins manage these keys self-service: dashboard,cow api-keys list|create|revoke, or the MCP tools, all over/v1/settings/api-keys; a key’s secret is shown exactly once, at creation. - Dashboard API (everything else): a Clerk session token, as issued by the dashboard sign-in or
cow login. Destructive actions additionally require theorg:adminrole; the same gates apply through the CLI and MCP server, which call the same endpoints.
Idempotency
Section titled “Idempotency”Retries must never double-write. track requires a client-supplied messageId; the same value may also be sent as the Idempotency-Key header (which takes precedence). A replay within the 3-day dedupe window returns the original event instead of writing a new one; dedupe keys are per environment, so the same messageId in development and production is two events. The @cowliss/sdk client generates messageIds automatically and retries network errors and 5xx with backoff; 4xx responses surface as typed errors, never retried.
identify is an idempotent upsert and needs no dedupe.
Rate limits and quotas
Section titled “Rate limits and quotas”Ingestion carries a per-org fixed-window limit (600 requests per 60 seconds), reported via standard RateLimit headers; 429 responses carry Retry-After. Separately, spend is metered against the org’s credit: when the monthly allowance plus wallet balance is exhausted, ingestion hard-rejects with 429 over_quota until a top-up lands. See Billing.
Versioning of everything else
Section titled “Versioning of everything else”The same freeze rule applies beyond the API: the journey DSL ships semvered majors (current: 1), destination webhook payloads carry v: 1 (destinations pin the version they expect), and CLI/MCP command and tool names are stable per major.