# API conventions

> Part of the Cowliss docs. Markdown for every page: append `index.md` to its URL. Site map: /llms.txt

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

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:

```json
{
  "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`.

:::note[Exception]
Public endpoints third-party platforms call (provider webhooks, mail delivery feedback, one-click unsubscribe, payments) answer per the calling platform's convention with plain 2xx bodies, not the envelope. Their own signature checks are their authorization.
:::

## 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:

```json
{
  "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

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.

## Naming and types

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

## 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`](/reference/api/settings/); 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](/guides/agents/#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.

## 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 `messageId`s 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

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](/guides/billing/).

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