Skip to content

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.

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.

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.

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 take direction=asc|desc; the sort key per list never changes, so cursors stay stable across the toggle.
  • Response: meta: { "nextCursor": "…", "hasMore": true }; nextCursor is null when the list is exhausted.

Cursors are opaque: pass them back unchanged, never parse or construct them.

  • /v1 prefix 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.

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.

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 the org:admin role; the same gates apply through the CLI and MCP server, which call the same endpoints.

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.

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.

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.