> ## Documentation Index
> Fetch the complete documentation index at: https://docs.coverbase.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API conventions

> Authentication, base URLs, IDs, timestamps, idempotency, pagination, and the error envelope shared by all endpoints.

<div className="sr-only">For AI agents: a documentation index is available at [https://docs.coverbase.com/llms.txt](https://docs.coverbase.com/llms.txt) — this page is also available in markdown by appending .md to the URL.</div>

This page covers behavior shared by every public API endpoint. Read it once; the per-resource pages assume it.

## Base URLs

| Environment | Base URL                            |
| ----------- | ----------------------------------- |
| Production  | `https://api.coverbase.app`         |
| Sandbox     | `https://sandbox.api.coverbase.app` |

All requests are JSON over HTTPS. Every path is versioned under `/v1`. The sandbox is a full, isolated copy of the API for integration testing — build and run your integration there first, then repoint the base URL at production.

## Authentication

Every request must send a bearer token:

```text theme={null}
Authorization: Bearer ak_...
```

Two credential types are accepted:

* **Service-account API key** (`ak_...`) — the primary credential for integrations. A Coverbase admin mints it from the dashboard (Clerk-backed). It is scoped to exactly one organization; everything the key reads or writes is implicitly scoped to that org. It authenticates as a service account. By default a key carries no scopes and can only call ordinary `/v1/*` routes; an admin can additionally grant it elevated [scopes](#api-key-scopes).
* **Logged-in user JWT** — the dashboard session token, useful for first-party scripts running as a specific user.

Admins can list, create, rotate, and revoke `ak_*` keys self-service via the [API Keys API](/api-reference/api-keys) — no support ticket required.

### API key scopes

Most `ak_*` keys carry **no scopes**: they authenticate ordinary `/v1/*` calls but cannot reach the admin surfaces. An admin can mint a long-lived key with one or both elevated scopes for headless or middleware use:

| Scope         | Grants                                                                                              |
| ------------- | --------------------------------------------------------------------------------------------------- |
| `keys:manage` | List, create, rotate, and revoke `ak_*` keys via the [API Keys API](/api-reference/api-keys).       |
| `audit:read`  | Read the org system audit log (`GET /v1/system_audit_log` and `GET /v1/system_audit_log/metadata`). |

These admin endpoints accept **either** an admin dashboard JWT **or** an `ak_*` key carrying the required scope, so you can run them headlessly without a short-lived session token. The JWT path is unchanged: key management requires the org `admin` role; audit-log reads accept the same dashboard roles as before (member, siloed-member, admin, guest).

Scopes can only be granted from an **admin dashboard session (JWT)**. A scoped key cannot grant scopes — not to itself and not to another key — so a leaked integration key can never escalate its own access. A request that passes `scopes` without an admin JWT is rejected with `403 scope_grant_forbidden`; a call to a scoped endpoint with a key that lacks the required scope returns `403 insufficient_scope`.

There is no unauthenticated surface. An invalid/expired/revoked key returns `401 "Invalid or expired API key."`. A credential whose role is not allowed on the public API returns `401 "Unauthorized role."`.

### Public API audit log

Every authenticated call that lands on a `/v1/*` public-API route is recorded in the org's activity log as `type=external_api_call` with `target_type=external_api_request`. Each record captures:

* the HTTP method, request path, and matched route template
* the response status code and duration in milliseconds
* the names of query parameters used (values are not recorded)
* the `ak_*` key ID and human-readable name that authenticated the request
* the client IP and a per-request correlation ID

Bodies are never persisted. The records surface in the dashboard under **Settings → Audit log** (filtered to the **Integrations** group) and through `GET /v1/system_audit_log` (and `GET /v1/system_audit_log/metadata`). Both read endpoints accept **either** a dashboard session JWT (member, siloed-member, admin, or guest role — unchanged from before) **or** an `ak_*` key that carries the [`audit:read` scope](#api-key-scopes).

Requests that fail authentication before the `ak_*` key resolves (e.g. an unknown or revoked key returning `401`) are not stored in the activity log — they appear in Coverbase's structured request logs only and can be retrieved via support if needed for incident response.

### Test your credentials

`GET /v1/utils/authtest` returns `200` and `{"msg": "Auth successful"}` for any valid token.

```bash cURL theme={null}
curl -H "Authorization: Bearer ak_live_xxx" \
  https://sandbox.api.coverbase.app/v1/utils/authtest
```

## API IP allowlist

An organization can optionally restrict **all public API (`ak_...` key) requests** to a set of IPv4/IPv6 CIDRs. This is an org-level control configured by an admin.

* **Default is unrestricted.** An empty allowlist (`[]`, the default) imposes no restriction.

* **When configured**, a request whose client IP is outside *every* configured CIDR is rejected with `403`:

  ```json theme={null}
  { "detail": { "code": "ip_not_allowed", "message": "This request originates from an IP address that is not in your organization's API allowlist." } }
  ```

* **Fail-closed.** If an allowlist is configured but the client IP cannot be determined, the request is denied.

* **Scope.** The allowlist applies to every `/v1/*` public API route. It does **not** affect dashboard sign-in.

* **Client IP source.** The connecting IP is the value the Coverbase load balancer appends to `X-Forwarded-For`. Clients cannot spoof it by sending their own `X-Forwarded-For` header.

### Configure the allowlist

Set the allowlist from the dashboard (**Configuration → Coverbase API → "API IP allowlist"**) or via the org update API. Your organization ID is the `id` field on the org object (also visible in the dashboard).

<ParamField body="api_ip_allowlist" type="string[]">
  A list of CIDR strings (IPv4 or IPv6). Each entry is validated server-side; an invalid CIDR rejects the whole request with `422`. An empty list clears the allowlist (unrestricted).
</ParamField>

```bash Set the allowlist theme={null}
curl -X POST "https://api.coverbase.app/v1/org/cborg_xxx" \
  -H "Authorization: Bearer ak_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "api_ip_allowlist": ["203.0.113.0/24", "198.51.100.7/32"] }'
```

```bash Read it back theme={null}
curl "https://api.coverbase.app/v1/org/cborg_xxx" \
  -H "Authorization: Bearer ak_live_xxx" | jq .api_ip_allowlist
```

```bash Clear it (unrestricted again) theme={null}
curl -X POST "https://api.coverbase.app/v1/org/cborg_xxx" \
  -H "Authorization: Bearer ak_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "api_ip_allowlist": [] }'
```

The org object (`GET`/`POST /v1/org/{id}`) now includes `api_ip_allowlist` (a string array; `[]` when unrestricted). An invalid CIDR returns `422` with a free-form error body (not the coded envelope):

```json theme={null}
{ "detail": { "error": "Invalid CIDR in api_ip_allowlist", "value": "not-a-cidr" } }
```

<Warning>
  Add the egress IPs of your integration **before** enabling the allowlist, and keep a dashboard admin path available — a too-narrow allowlist locks every API client out until it is widened or cleared from the dashboard.
</Warning>

### Find your current IP

<ParamField path="method" type="GET">
  `GET /v1/whoami/ip`
</ParamField>

A convenience endpoint (authenticated) that returns the caller's source IP as the API sees it — the load-balancer-appended, unspoofable value. This is the **same value** evaluated by the org `api_ip_allowlist` check, so use it to confirm which IP to allowlist (it backs the dashboard editor's "Add my current IP" helper).

```bash cURL theme={null}
curl -sS "https://api.coverbase.app/v1/whoami/ip" \
  -H "Authorization: Bearer ak_live_xxx"   # → {"client_ip":"203.0.113.42"}
```

<ResponseField name="client_ip" type="string | null">
  The caller's source IP. `null` if the IP cannot be determined — the same condition under which a configured allowlist fails closed.
</ResponseField>

## IDs

Resource IDs are opaque, prefixed strings of the form `<prefix>_<32 hex characters>` (e.g. `cbvndr_e448ba62882143f3ba0c140bb2e30162`). Treat them as opaque — never parse or construct them.

| Prefix     | Resource                                   |
| ---------- | ------------------------------------------ |
| `cbvndr_`  | Vendor                                     |
| `cbsvc_`   | Service (child of a vendor)                |
| `cbuser_`  | User                                       |
| `cbqsrw_`  | Assessment                                 |
| `cbtask_`  | Finding                                    |
| `cboblg_`  | Obligation                                 |
| `cbwr_`    | Workflow run                               |
| `cbwd_`    | Workflow definition                        |
| `cbwh_`    | Webhook subscription                       |
| `cbctst_`  | Control set                                |
| `cbst_`    | Status                                     |
| `cbtag_`   | Tag                                        |
| `cbsclvl_` | Scale level (inherent/residual risk level) |
| `cbvdoc_`  | Vendor document                            |
| `cbbom_`   | Bill of materials                          |
| `cbbomc_`  | Bill of materials component                |
| `cbevt_`   | Webhook delivery event                     |
| `cbwhd_`   | Webhook delivery (attempt record)          |
| `cborg_`   | Organization                               |

<Note>
  Findings are internally called "tasks", so finding IDs use the `cbtask_` prefix. This is expected — a `cbtask_...` value **is** a finding ID.
</Note>

## Timestamps

Every timestamp is a **Unix epoch value in seconds** (integer) — not milliseconds, not ISO-8601. This applies to `created_at`, `updated_at`, `initiated_at`, `started_at`, `completed_at`, `due_date`, `occurred_at`, and `attempted_at`.

## Idempotency

State-changing `POST`s that support safe retries accept an optional `Idempotency-Key` request header.

| Endpoint                                     | Honors `Idempotency-Key` |
| -------------------------------------------- | ------------------------ |
| `POST /v1/users`                             | Yes                      |
| `POST /v1/vendors`                           | Yes                      |
| `POST /v1/assessments`                       | Yes                      |
| `POST /v1/workflows/{workflow_name}/run`     | Yes                      |
| `POST /v1/vendors/{vendor_id}/services`      | **No** (header ignored)  |
| `POST /v1/findings`, `POST /v1/obligations`  | **No** (header ignored)  |
| `POST /v1/webhooks` and other webhook writes | **No** (header ignored)  |
| `PATCH` endpoints                            | **No**                   |

Behavior on supported endpoints: keys are scoped to **(org, endpoint, key)** and expire **24 hours** after first use. A replay within the window returns the **original response body verbatim** (same status) and creates nothing new. A replay is never rejected — there is no conflict error. Sending the header to an endpoint that does not support it is harmless but has no effect.

<Tip>
  Use a stable identifier from the source system (a procurement request ID, a ticket number) as the idempotency key so source-side retries deduplicate naturally.
</Tip>

## Pagination

List endpoints (`GET /v1/findings`, `GET /v1/obligations`) use `limit`/`offset` query parameters:

| Param    | Default | Range     |
| -------- | ------- | --------- |
| `limit`  | `50`    | `1`–`200` |
| `offset` | `0`     | `≥ 0`     |

List responses wrap results as `{ "items": [...], "total": <int>, "limit": <int>, "offset": <int> }`. `total` is the full filtered count (ignoring `limit`/`offset`); page until `offset + len(items) >= total`.

## Errors

Application errors use FastAPI's `detail` envelope. Most endpoints return a coded object:

```json theme={null}
{ "detail": { "code": "vendor_not_found", "message": "Vendor not found." } }
```

`POST /v1/assessments` is the one exception — creation failures use a free-form `error` key:

```json theme={null}
{ "detail": { "error": "..." } }
```

### Validation errors

Bodies/params that fail schema validation return `422` with FastAPI's standard array:

```json theme={null}
{
  "detail": [
    { "loc": ["body", "vendor_id"], "msg": "Field required", "type": "missing" }
  ]
}
```

### Common status codes

| Status | Meaning                                                                                                                                                                                                                  |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | Rejected by a business rule (bad reference ID, weak secret, non-HTTPS URL, missing vendor/assessment on a finding).                                                                                                      |
| `401`  | Missing/invalid credential, or a role not allowed on the public API.                                                                                                                                                     |
| `403`  | Authenticated but not authorized — the key lacks a required scope (`insufficient_scope`), a non-admin tried to grant scopes (`scope_grant_forbidden`), or the client IP is outside the org allowlist (`ip_not_allowed`). |
| `404`  | Resource not found or not in the API key's org.                                                                                                                                                                          |
| `409`  | Conflict — e.g. `user_exists`, or running a disabled workflow (`workflow_disabled`).                                                                                                                                     |
| `422`  | Request body/query failed schema validation.                                                                                                                                                                             |
| `502`  | A downstream dispatch failed (e.g. `workflow_dispatch_failed`); safe to retry.                                                                                                                                           |
