Developers

API reference

The Conformant API lets your own app read and write a workspace's data over HTTP. A person signs in to your app with Conformant, and the app then acts as that person: it sees what they may see and can do what they may do, as the workspace's schema says.

To connect an AI assistant such as Claude or ChatGPT to a workspace instead, add Conformant's MCP server to it: https://conformant.com/mcp.

Overview

Requests and responses are JSON, under https://conformant.com/api/v1, and each request carries Authorization: Bearer <access token>. A token comes from a person signing in to your app; there are no API keys.

Origin          https://conformant.com
API             https://conformant.com/api/v1
Authorize       https://conformant.com/authorize
Token           https://conformant.com/token
Register        https://conformant.com/register
Discovery       https://conformant.com/.well-known/oauth-authorization-server

A few things shape how an app on the API is built:

  • The schema is the source of names. Models, fields and actions are whatever the workspace's schema declares, spelled exactly; names are case-sensitive. GET …/schema.plato returns it.
  • Reads are queries; writes are actions. There is no generic update. An app changes data by calling the actions the schema declares, so a change no action covers needs the schema extended first.
  • Permissions are the workspace's. It decides what each person may read and do, so the same query returns different rows to different people, and an app needs no per-user filtering or permission checks of its own.
  • A refused write is refused. It comes back as an error, nothing is saved, and nothing is queued for approval.
  • No backend is required. A browser may call the API directly from any origin.

Example client

This module covers what a browser app needs: sign-in, token refresh and the API's calls. It can be used as it stands or as a starting point; the sections after it explain each part.

// conformant.js: sign-in and calls, for an app that runs in the browser.
const ORIGIN = "https://conformant.com";
const API = `${ORIGIN}/api/v1`;
const REDIRECT_URI = `${location.origin}${location.pathname}`;
const STORE = "conformant";

const load = () => JSON.parse(localStorage.getItem(STORE) ?? "{}");
const save = (state) => localStorage.setItem(STORE, JSON.stringify(state));
const base64url = (bytes) =>
  btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));

// Registers once per browser and keeps the client id: registration is
// rate-limited, so it is not repeated on every page load.
async function clientId() {
  const state = load();
  if (state.clientId) return state.clientId;
  const response = await fetch(`${ORIGIN}/register`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      client_name: "My app",              // shown to the person on approval
      redirect_uris: [REDIRECT_URI],      // must match exactly later
      token_endpoint_auth_method: "none", // a public client: no secret
      grant_types: ["authorization_code", "refresh_token"],
      response_types: ["code"],
    }),
  });
  if (!response.ok) throw new Error(`Registration failed: ${response.status}`);
  const { client_id } = await response.json();
  save({ ...state, clientId: client_id });
  return client_id;
}

// Step 1: send the browser to Conformant.
export async function signIn(workspaceId) {
  const verifier = random();
  const state = random();
  sessionStorage.setItem(STORE, JSON.stringify({ verifier, state }));
  const challenge = base64url(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)),
  );
  const url = new URL(`${ORIGIN}/authorize`);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: await clientId(),
    redirect_uri: REDIRECT_URI,
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
    resource: API,   // required: without it the token is not for this API
    scope: "api",
  });
  // Optional: the workspace your app is built on, so nobody is asked which.
  const workspace = workspaceId ?? load().workspaceId;
  if (workspace) url.searchParams.set("workspace", workspace);
  location.assign(url);
}

// Step 2: call on every page load. Finishes a sign-in when Conformant has
// redirected back with ?code=…&state=…
export async function completeSignIn() {
  const params = new URLSearchParams(location.search);
  if (!params.has("code") && !params.has("error")) return;
  history.replaceState(null, "", REDIRECT_URI);
  const pending = JSON.parse(sessionStorage.getItem(STORE) ?? "{}");
  sessionStorage.removeItem(STORE);
  if (params.has("error")) throw new Error(params.get("error_description") ?? params.get("error"));
  if (params.get("state") !== pending.state) throw new Error("Sign-in did not start here");
  const ok = await token({
    grant_type: "authorization_code",
    code: params.get("code"),
    redirect_uri: REDIRECT_URI,
    code_verifier: pending.verifier,
  });
  if (!ok) throw new Error("Conformant refused the sign-in");
}

async function token(params) {
  const response = await fetch(`${ORIGIN}/token`, {
    method: "POST", // form-encoded, not JSON
    body: new URLSearchParams({ client_id: await clientId(), resource: API, ...params }),
  });
  if (!response.ok) return false;
  const tokens = await response.json();
  // Refresh tokens rotate: always store the new one.
  save({ ...load(), accessToken: tokens.access_token, refreshToken: tokens.refresh_token });
  return true;
}

export const signedIn = () => Boolean(load().accessToken);

export function signOut() {
  const { accessToken, refreshToken, ...kept } = load();
  save(kept);
}

// Step 3: call the API. Refreshes an expired token once, then gives up.
async function call(method, path, body) {
  const send = () =>
    fetch(`${API}${path}`, {
      method,
      headers: {
        authorization: `Bearer ${load().accessToken}`,
        ...(body ? { "content-type": "application/json" } : {}),
      },
      ...(body ? { body: JSON.stringify(body) } : {}),
    });
  let response = await send();
  if (response.status === 401 && load().refreshToken) {
    const refreshed = await token({
      grant_type: "refresh_token",
      refresh_token: load().refreshToken,
    });
    if (refreshed) response = await send();
  }
  if (response.status === 401) {
    signOut(); // revoked, or the person left the workspace: sign in again
    throw Object.assign(new Error("Signed out"), { status: 401 });
  }
  const answer = await response.json();
  if (!response.ok) {
    // A problem document: { type, title, detail? }
    throw Object.assign(new Error(answer.detail ?? answer.title), {
      status: response.status,
      type: String(answer.type).split("/").pop(), // e.g. "permission_required"
    });
  }
  return answer;
}

export async function me() {
  const answer = await call("GET", "/me");
  save({ ...load(), workspaceId: answer.workspace.id });
  return answer;
}

const inWorkspace = async (method, path, body) => {
  const workspaceId = load().workspaceId ?? (await me()).workspace.id;
  return call(method, `/workspaces/${workspaceId}${path}`, body);
};

export const query = (text) => inWorkspace("POST", "/query", { query: text });
export const actions = (calls, options = {}) =>
  inWorkspace("POST", "/actions", { calls, ...options });
export const check = (calls) => inWorkspace("POST", "/actions/check", { calls });

On page load, call completeSignIn(), then show either a "Sign in with Conformant" button that calls signIn(), or the app if signedIn().

Sign in

Sign-in is OAuth 2.0's authorization code flow with PKCE, with the app as a public client (no client secret). The three steps:

  • Register the app once with POST /register and keep the client_id. Registration and /authorize share a limit of 20 requests a minute per address, so an app registers once and reuses its client id.
  • Redirect the browser to /authorize with response_type=code, your client_id, redirect_uri, state, a PKCE code_challenge with code_challenge_method=S256, resource=https://conformant.com/api/v1 and scope=api. Conformant signs the person in, asks them to approve the app, and redirects back with code and state.
  • Exchange the code with POST /token, form-encoded: grant_type=authorization_code, code, redirect_uri, client_id, code_verifier and resource. The answer has an access_token and a refresh_token.

Notes

  • resource is required. Without it the token is for a different service and every API call answers 401.
  • The redirect_uri must equal one you registered, character for character.
  • An access token is short-lived. On a 401, exchange the refresh token (grant_type=refresh_token) and retry once. Each refresh returns a new refresh token; store it, because the old one stops working. If the refresh fails, send the person through sign-in again.
  • A token is for one workspace, the one the person chose when approving. GET /api/v1/me says which.
  • If your app is built on one workspace, add workspace=<workspace id> to the /authorize request and the person is not asked which workspace. The id is the workspace.id that /me returns.
  • The first time a person connects an app they are asked to approve it. Sign-in cannot be made silent.

A deployed app

Instead of registering, a deployed app can use the URL of a metadata document as its client_id. Conformant fetches it, and shows people the app's domain as verified. The URL must be HTTPS and the document must name itself:

// Served at https://app.example.com/oauth-client.json
{
  "client_id": "https://app.example.com/oauth-client.json",
  "client_name": "Example app",
  "redirect_uris": ["https://app.example.com/"]
}

Who is signed in

GET /api/v1/me
Authorization: Bearer <access token>

200
{
  "user": { "id": "31873151…9accf", "name": "Ana Reyes" },
  "workspace": {
    "id": "5d398672-6935-4962-9646-d69c58053fe3",
    "slug": "harbour",
    "name": "Harbour Constructions"
  },
  "scope": "write"
}
  • user.id identifies the person, and is the same in every workspace, so it suits a user key. user.name may be null.
  • workspace.id goes in the path of every other call.
  • scope is how far the person lets this app act: read, propose or write. Only write may call actions. The person can change it in Conformant at any time, so a write may still be refused after it was read.

The schema

The schema is usually read once, while building an app, by whoever is writing it; a running app rarely needs it. It is shown on the workspace's schema page in Conformant, and this call returns it as plain text:

GET /api/v1/workspaces/{workspaceId}/schema.plato
Authorization: Bearer <access token>

200
Content-Type: text/plain; charset=UTF-8

role Clerk

model Task {
  fields {
    title String
    done Boolean
  }

  actions {
    create addTask(id, title) by Clerk {
      done = false
    }
    update finish(id) where (done == false) by Clerk {
      done = true
    }
  }

  reads {
    Clerk
  }
}

The response is the workspace's schema in Conformant's schema language. It is where every name an app uses comes from:

  • Models and fields, which queries select.
  • Actions, which writes call as Model/actionName. An action lists its arguments in parentheses, the fields it may set after with, the condition it requires after where, and who may run it after by.
  • Roles, which say who may read a model (reads) and run an action (by).

A person who reads only part of the database gets just the models and fields they can see, without read rules, so the whole schema is the one an administrator's token returns. Someone who can read nothing gets 403 database_read_forbidden.

GET …/schema, without the extension, is reserved for the schema as JSON and answers 501 not_implemented for now.

Query

POST /api/v1/workspaces/{workspaceId}/query
Content-Type: application/json

{ "query": "Task @where(done == false) @orderBy(title) @first(50) { title done }" }

200
{
  "rows": [
    { "id": "3f6c…", "title": "Order steel", "done": false }
  ],
  "pageInfo": { "hasNextPage": false, "endCursor": "eyJ…" }
}
  • The query language is described in the query reference: filters, ordering, relations, aggregates and paging.
  • Every row carries its id, selected or not. Use it as the id argument of a later action.
  • Page with @first(n). When pageInfo.hasNextPage is true, send the same query with @after("<endCursor>") added. pageInfo is null for a query that does not page.
  • A query that would read too much is refused whole, never cut short. Filter it, select less, or page it.
  • There is no push or subscription yet. To show fresh data, run the query again, for example after each action and when the page regains focus.

Write with actions

POST /api/v1/workspaces/{workspaceId}/actions
Content-Type: application/json

{
  "calls": [
    { "action": "Task/addTask", "args": { "title": "Order steel" } },
    { "action": "Task/finish", "args": { "id": "3f6c…" } }
  ],
  "title": "Add and finish tasks",
  "requestId": "b0a1c6de-5c1e-4a52-9d0a-0d6a4d0c8a11"
}

200
{ "created": [{ "model": "Task", "id": "9b2e…" }] }
  • calls apply together as one change, in order, or not at all. If any call is refused, nothing is saved.
  • action is Model/actionName from the schema. args holds the action's arguments by name. For an update or delete, the record goes in args.id.
  • A create may leave out args.id; Conformant chooses one. Or supply your own: 1 to 128 characters of letters, digits, . _ : -.
  • created lists every record the calls made, in order, with the ids chosen.
  • title (optional) says what the change is for, and shows in the workspace's activity.
  • requestId (optional) makes a retry safe. Send a fresh UUID per user action; repeating the request with the same id and calls returns the first result without applying twice. The same id with different calls is 409 request_reused.
  • Every model also has Model/create, Model/update and Model/delete, which the schema does not list. Only the workspace's administrators may run them, so an app for other people uses the schema's own actions.
  • ifAbsent: true on a create leaves an existing record of that id as it is, so an import can run twice.

Argument values

  • String, Boolean, Int and Float are JSON strings, booleans and numbers.
  • Date is "YYYY-MM-DD". DateTime is an ISO 8601 instant with its offset, such as "2026-11-14T17:00:00Z".
  • Currency is an integer number of cents: 1850000 is $18,500.00. Queries return it the same way.
  • A reference to another record is that record's id as a string, never an object.
  • An enum is one of its values as a string, spelled as the schema spells it.
  • A Document field takes its whole text as Markdown. Read it in a query as notes { markdown }.
  • null clears an optional field.

Check before writing

POST /api/v1/workspaces/{workspaceId}/actions/check
Content-Type: application/json

{ "calls": [{ "action": "Task/finish", "args": { "id": "3f6c…" } }] }

200
{ "results": [{ "valid": false, "error": "…why it would be refused…" }] }

actions/check says whether each call would be accepted right now, and changes nothing. Use it to disable a button or explain why something cannot be done. Each call is judged on its own, and results is in the order of calls; error is null when valid is true.

Errors

A refusal is a 4xx with a problem document. The last part of type names it; show detail or title to the person.

403
{
  "type": "https://conformant.com/problems/permission_required",
  "title": "…",
  "detail": "…"
}
  • 401: no token, or one that no longer works (expired, revoked, or the person left the workspace). Refresh once, then sign in again. This one has no problem document.
  • 403 agent_scope: the person has not let this app write. Tell them to allow it in Conformant, under Settings, Connections.
  • 403 permission_required: the person may not run that action, or not on that record. Retrying will not change the answer.
  • 403 database_read_forbidden: the person can read nothing in this workspace.
  • 403 token_workspace: the path names a different workspace from the token's. Use workspace.id from /me.
  • 409 request_reused: a requestId was sent again with different calls.
  • 400 mutation_refused: an action is unknown, refused its arguments, or its where condition does not hold for that record now. title says which. Nothing was saved.
  • 400, other types: a query that does not parse or names something the schema lacks, or a body that is not what the route takes (validation_error). The same request will be refused again.

What the API does not do yet

  • Realtime updates. Run queries again to refresh.
  • File and image fields cannot be uploaded or downloaded.
  • Comments and change history are not available.
  • There are no proposals: a write the person may not make is refused, not put up for someone else to review.
  • The schema cannot be changed through the API.
  • There are no API keys for a server acting on its own. A workspace admin can connect a GitHub Actions workflow in Settings, Connections, which then calls the API with the job's own OIDC token.

Stability

Within /api/v1, fields and paths are added but never renamed or removed, so code written against this page keeps working. Ignore fields you do not recognise.