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/openapireturns 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.
curl "$CONTINUAL_URL/api/openapi" -H "Authorization: Bearer $CONTINUAL_API_KEY"The standard record types are listed in Standard 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.
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.
Copy the key
The key starts with
cos_. It is shown once, and Continual stores only its hash. Keep it secret.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.
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-organizationheader. - 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.
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:
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"
}'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()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 samerequestIdand 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:
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" }'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" }),
}
)To run several Actions in one transaction, use records.run (POST /api/v1/records:run).
Lists, filters, and paging
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'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()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
nextPageTokenaspageTokento get the next page. - The response holds
itemsandnextPageToken. Lists also returntotalSize.
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:
{
"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.
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.