Skip to content
Cowliss is in beta: features and pricing may change.

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.

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.

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.

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_, 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.

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 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 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.

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.

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.