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.
A request that succeeded but deserves a second look carries meta.warnings, a list of { "code", "message", "suggestion"? }. Adding a root sending domain is the one case today: it answers 201 with a root_domain warning whose suggestion is a subdomain such as mail.example.com.
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 |
source_inactive |
403 | The source the call names is inactive; activate it to send again |
plan_limit |
403 | Your plan holds no more apps or sending domains; the message names the limit and the way up |
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 |
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 provider Cowliss depends on 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.
A validation_failed for invalid fields also lists them in error.issues (the first 100), not only the first. Each issue has a path into the payload (inside data) and a message. An entry of a list that has a key is named by that key, any other entry by its index, and an object key that is not a plain name is quoted. message is the first issue’s message.
On a push (POST /v1/pushes and POST /v1/pushes/plan), an issue inside one of the resources the push carries also names that resource (kind, key, and index in its list), and message starts with the first issue’s path and counts the rest:
{ "error": { "code": "validation_failed", "message": "templates[\"Receipt_BAD\"].key: key must be kebab-case (a-z, 0-9, hyphens) (and 1 more)", "issues": [ { "path": "templates[\"Receipt_BAD\"].key", "message": "key must be kebab-case (a-z, 0-9, hyphens)", "resource": { "kind": "template", "key": "Receipt_BAD", "index": 1 } }, { "path": "templates[\"shipped\"].template.subject", "message": "a subject is required", "resource": { "kind": "template", "key": "shipped", "index": 2 } } ] }, "meta": { "requestId": "7f3c…" }}kind is one of automation, template, segment, purpose or events (the event list), the kinds a plan’s conflicts use; key is null when the entry has none, index is null for an issue about the whole list.
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_,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.
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 key scoped to 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 session token, as issued by the dashboard sign-in or
cow login, or the access token an MCP client holds after connecting to the MCP URL. 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 organization. The @cowliss/sdk client generates messageIds automatically and retries network errors and 5xx with backoff; 4xx responses surface as typed errors, never retried.
A send (POST /v1/emails) honours an Idempotency-Key header the same way: a repeat replays the first successful answer, and a send that failed frees its key, so retrying with the same key (after you fix what it refused) is a fresh attempt. A repeat while the first is still in flight answers 409 concurrent_idempotent_requests. The SDK’s emails.send sets a fresh key per call unless you pass your own idempotencyKey, so its retries never send twice.
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 month’s included usage plus wallet balance is exhausted, ingestion hard-rejects with 429 over_quota until a top-up lands or the next month begins. Plan limits are separate again: creating an app or a sending domain past your plan’s limit answers 403 plan_limit. See Billing.
Versioning of everything else
Section titled “Versioning of everything else”The same freeze rule applies beyond the API: the automation DSL ships semvered majors (current: 1), webhook payloads carry v: 1 (a webhook pins the version it expects), and CLI/MCP command and tool names are stable per major.