# REST API

> Call every Continual operation over HTTP: authentication, routes, Actions, paging, errors, and changes.

Every operation your organization can use is an HTTP route under `/api/v1` on your Continual
address. This page covers what every organization's API shares. The examples keep your address in
`CONTINUAL_URL` and an API key in `CONTINUAL_API_KEY`.

## Your organization's API

Your organization's API depends on the apps it has installed, including any apps of its own. Two
places describe it exactly:

- `GET /api/openapi` returns an OpenAPI document. With your API key, it describes your
  organization's model. Without credentials, it describes only Continual's apps.
- In the web app, **Settings › Domain model** shows your organization's record types, their fields,
  and their operations.

```bash
curl "$CONTINUAL_URL/api/openapi" -H "Authorization: Bearer $CONTINUAL_API_KEY"
```

The standard record types are listed in [Standard record types](/docs/developers/record-types).

## Authentication

A request authenticates in one of two ways.

| Credential    | How to send it                | Who it is                             |
| ------------- | ----------------------------- | ------------------------------------- |
| API key       | `Authorization: Bearer cos_…` | A service account of one organization |
| Login session | The `company_session` cookie  | A person signed in to the web app     |

Use an API key for code and agents.

<Steps>
  <Step title="Create a service account">
    An administrator opens **Settings › Service accounts** and adds one. They
    name it, choose its kind, **Integration** or **Agent**, and give it a role.
    Adding it issues its first key.
  </Step>
  <Step title="Copy the key">
    The key starts with `cos_`. It is shown once, and Continual stores only its
    hash. Keep it secret.
  </Step>
  <Step title="Revoke the key when you no longer need it">
    On the same page, administrators can issue more keys for an account and
    revoke any of them. Anything that uses a revoked key loses access at once.
  </Step>
</Steps>

An administrator can do the same through the API, with `serviceAccounts.create` and
`serviceAccounts.issueKey`. A key may have an expiry.

### Choose the organization

Each request works in one organization.

- An API key works only in its own organization, so its requests need no organization header.
- A signed-in person names the organization with the `x-company-organization` header.
- A change made with a session cookie must come from the web app's own origin.

To check who a credential is, call `me`. It returns the caller, its kind, its organization, its
permissions there, and the installed apps.

```bash
curl "$CONTINUAL_URL/api/v1/me" -H "Authorization: Bearer $CONTINUAL_API_KEY"
```

### Act for a person

An agent-kind service account can name a person it acts for with the `x-company-on-behalf-of`
header, set to that person's user ID. The person must be an active member of the account's
organization. The request then reaches exactly what that person may reach, and its changes record
both.

Only an account that holds a system role allowing `platform.members.actAs` gets any access this way,
such as Continual's `agent.delegate` or an app endpoint's `platform.appDelegate`. An agent account
an admin creates holds no such role, so a request it sends with `x-company-on-behalf-of` gets no
grants.

An agent can also name the record its writes are for, such as its session, with `x-company-cause`.

## Routes

Routes follow the operation IDs in the OpenAPI document. A record operation takes the record's ID in
the path. Where an Object has handles, such as `INV-1042`, a handle works in place of the ID.

| Operation                | Route                                      |
| ------------------------ | ------------------------------------------ |
| `companies.list`         | `GET /api/v1/companies`                    |
| `companies.get`          | `GET /api/v1/companies/{id}`               |
| `companies.create`       | `POST /api/v1/companies`                   |
| `companies.update`       | `PATCH /api/v1/companies/{id}`             |
| `companies.delete`       | `DELETE /api/v1/companies/{id}`            |
| `finance.invoices.issue` | `POST /api/v1/finance.invoices/{id}:issue` |
| `records.search`         | `POST /api/v1/records:search`              |
| `records.activity`       | `POST /api/v1/records/{id}:activity`       |
| `me`                     | `GET /api/v1/me`                           |

Request and response bodies are JSON.

## Actions

Create a company:

<CodeGroup>

```bash curl
curl -X POST "$CONTINUAL_URL/api/v1/companies" \
  -H "Authorization: Bearer $CONTINUAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Northstar Robotics",
    "domain": "northstar.example",
    "requestId": "import-northstar"
  }'
```

```ts TypeScript
const response = await fetch(`${process.env.CONTINUAL_URL}/api/v1/companies`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CONTINUAL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Northstar Robotics",
    domain: "northstar.example",
    requestId: "import-northstar",
  }),
})
const { result, records, position } = await response.json()
```

</CodeGroup>

Continual sets the record's `organization` to the organization the request works in. The field is
output only, so you never send it.

Every Action takes two optional fields beside its input:

- `requestId`: an ID you choose for the write. A retry with the same `requestId` and input returns
  the first result and does not write again.
- `validateOnly`: run every check and return the result the Action would produce, without
  committing.

A record Action can also name the `revision` it read. If the record has changed since, the Action
fails with `ABORTED`, so you never overwrite a change you did not see.

Every Action answers with the same shape:

| Field      | What it holds                                                      |
| ---------- | ------------------------------------------------------------------ |
| `result`   | The Action's result, such as the record it created                 |
| `records`  | Each record the transaction created or changed that you can read   |
| `deleted`  | The IDs of the records it deleted                                  |
| `position` | The commit log position of the commit; `null` when nothing changed |

Issue a draft invoice by its handle:

<CodeGroup>

```bash curl
curl -X POST "$CONTINUAL_URL/api/v1/finance.invoices/INV-1042:issue" \
  -H "Authorization: Bearer $CONTINUAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "requestId": "issue-INV-1042" }'
```

```ts TypeScript
await fetch(
  `${process.env.CONTINUAL_URL}/api/v1/finance.invoices/INV-1042:issue`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CONTINUAL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ requestId: "issue-INV-1042" }),
  }
)
```

</CodeGroup>

To run several Actions in one transaction, use `records.run` (`POST /api/v1/records:run`).

## Lists, filters, and paging

<CodeGroup>

```bash curl
curl -G "$CONTINUAL_URL/api/v1/companies" \
  -H "Authorization: Bearer $CONTINUAL_API_KEY" \
  --data-urlencode 'filter={"field":"name","operator":"startsWith","value":"North"}' \
  --data-urlencode 'sort=[{"field":"name","direction":"asc"}]' \
  --data-urlencode 'pageSize=20'
```

```ts TypeScript
const query = new URLSearchParams({
  filter: JSON.stringify({
    field: "name",
    operator: "startsWith",
    value: "North",
  }),
  sort: JSON.stringify([{ field: "name", direction: "asc" }]),
  pageSize: "20",
})
const response = await fetch(
  `${process.env.CONTINUAL_URL}/api/v1/companies?${query}`,
  {
    headers: { Authorization: `Bearer ${process.env.CONTINUAL_API_KEY}` },
  }
)
const { items, nextPageToken, totalSize } = await response.json()
```

</CodeGroup>

Lists, search, revisions, and activity page the same way:

- Send `pageSize`, at most 100. With none or zero, you get 50.
- Send the previous response's `nextPageToken` as `pageToken` to get the next page.
- The response holds `items` and `nextPageToken`. Lists also return `totalSize`.

A list's `filter` and `sort`, and an aggregate's `filter` and `sum`, travel as JSON in their query
parameters. A field filter names a `field`, an `operator`, and a `value`. Operators include `eq`,
`in`, `gt`, `lt`, `contains`, `startsWith`, and `isNull`, and filters combine with `and`, `or`, and
`not`.

Each Object's filter schema is in the OpenAPI document. A list also takes `query`, a text search
across the Object's search fields and record names. Lists and searches return only the records the caller can reach.

## Errors

Every error has the same body:

```json
{
  "status": "FAILED_PRECONDITION",
  "reason": "APPROVAL_REQUIRED",
  "message": "…",
  "details": {
    "record": "…",
    "action": "finance.invoices.issue",
    "policy": "…"
  }
}
```

`status` is the canonical code, and it sets the HTTP status. `reason` is the stable cause to match
in code. Validation errors list `violations` in `details`, each with the field's `path`.

| Status                | HTTP | Common causes                                              |
| --------------------- | ---- | ---------------------------------------------------------- |
| `INVALID_ARGUMENT`    | 400  | The input or a business rule fails validation              |
| `FAILED_PRECONDITION` | 400  | A document is final, or a policy holds or refuses the call |
| `UNAUTHENTICATED`     | 401  | No credential, or one that does not verify                 |
| `PERMISSION_DENIED`   | 403  | The caller's roles do not allow it, or it is not admitted  |
| `NOT_FOUND`           | 404  | No such record or route                                    |
| `ABORTED`             | 409  | The record moved past the `revision` you sent              |
| `ALREADY_EXISTS`      | 409  | A record already holds a unique value                      |

When a policy holds a call, the reason is `APPROVAL_REQUIRED`. Ask a person with
`approvals.request`, naming the held call. When a policy refuses a call, the reason is
`POLICY_DENIED`. See [Approvals](/docs/concepts/approvals).

Every response carries its request's ID in the `x-request-id` header. Include it when you report a
problem.

## Follow changes

`GET /api/v1/events` returns the events the caller can see, in commit order. An event is one
record's part of a commit, named for what happened: `finance.invoice.issued` when an invoice is
issued, or `created`, `updated`, or `deleted` after the record type for a plain write. The `x-event`
of an operation in the OpenAPI document gives the name its Action makes. Events of one Action share
their `commit` and `position`, and `type` keeps one kind, such as `type=finance.invoice.issued`.
`object` keeps one record type's events, and with `filter`, a filter over that type's own fields as
JSON, only those whose record matches it before or after, so you see a record arrive, change, and
leave what the filter describes.

Each page returns a `nextCursor`. Send it back as `cursor` to continue, even after an empty page.
Send `cursor=now` to start at the current head.

<Cards>
  <Card title="MCP" href="/docs/developers/mcp" icon="plug">
    Let an agent find and run the same operations as tools.
  </Card>
  <Card
    title="Standard record types"
    href="/docs/developers/record-types"
    icon="list"
  >
    Every record type Continual's apps define.
  </Card>
  <Card title="Approvals" href="/docs/concepts/approvals" icon="shield">
    How policies hold a call for a person's yes.
  </Card>
</Cards>
